Authoring a Plugin
Part of the Harness Engineer Guide. Prerequisite: Anatomy of a Stage. Design reference (the mechanism, the install-time rationale, the hybrid distribution model, and as-built status): Plugin Mechanism.
An AIDLC plugin (a plugin) is a reusable, optional set of AIDLC
contributions - new stages, agents, scopes,
sensors, methodology knowledge, and additive modifications to existing core
stages — packaged in its own directory,
published from its own repository, and composed into a user's install over
their chosen set of plugins. A plugin never edits core/; with every plugin
disabled, the install behaves as bare core does (the plugins' files stay
installed, so one can be enabled again).
First-party plugins (shipped by the AIDLC team) and third-party plugins (anyone else) are mechanically identical — same structure, same seams, same composer, same guarantees. The only difference is provenance: whose repository the plugin lives in and who reviewed it.
Start a new repository with aidlc-plugin-create.ts; this chapter then walks
the richer test-pro reference plugin end to end.
When to write a plugin vs. a plain stage/rule
- A stage/agent/rule (chapters 2–6) is a permanent part of the framework everyone gets.
- A plugin is optional and owned — it ships in its own repo, activates
only under an opt-in scope (and/or a
when:predicate), and a consumer chooses to compose it into their install. Use it for a domain pack (a full operation phase, a compliance plugin, a testing plugin) that not every project wants.
1. The directory + manifest
A plugin is a directory (and a git repository) with a declarative manifest and core-shaped subtrees:
test-pro/
.aidlc-plugin/plugin.json # the manifest
stages/construction/test-pro-integration.md # NEW stages
stages/operation/test-pro-full-suite.md
contributions/construction/nfr-requirements.md # MODIFY existing core stages (§3)
contributions/construction/nfr-design.md
contributions/construction/build-and-test.md
contributions/operation/performance-validation.md
sensors/aidlc-coverage-threshold.md # NEW sensor manifests
sensors/aidlc-requirement-coverage.md
tools/aidlc-sensor-coverage-threshold.ts # the sensor scripts
tools/aidlc-sensor-requirement-coverage.ts
tools/test-pro-doctor.ts # optional /aidlc --doctor checks
scopes/test-pro-validation.md # NEW plugin scope
agents/test-pro-metrics-agent.md # NEW support persona
knowledge/test-pro-metrics-agent/methodology.md # plugin methodology knowledge
tests/plugin.test.ts # plugin content and compose tests
.aidlc-plugin/plugin.json is a declarative manifest. Its top level mirrors
the common plugin-manifest shape (so a marketplace or host tooling can
list/version/trust it); AIDLC-specific configuration lives in a nested aidlc
block:
{
"name": "test-pro", // == dir name; "core", "aidlc", and "aidlc-*" are reserved
"version": "0.1.0", // semver
"description": "Full-featured testing plugin — unit/branch coverage, functional, integration, regression, edge, and API positive+negative.",
"author": { "name": "AWS AIDLC" },
"dependencies": ["core"], // core plus at most one base, e.g. ["core", "compliance@^1.2.0"]
"aidlc": {
// "marketplace": "acme-aidlc-plugins", // optional, your plugin's catalogue (below)
"contributes": { // which subtrees this plugin ships
"stages": "stages/", // NEW stage files
"overlays": "contributions/", // CONTRIBUTION files (§3 — modify existing)
"agents": "agents/", // NEW personas
"scopes": "scopes/", // NEW scope identities
"knowledge": "knowledge/", // methodology knowledge for agents
"sensors": "sensors/", // sensor manifests
"tools": "tools/" // runnable sensor + doctor scripts
}
}
}
contributes declares the conventional plugin subtrees. Configurable routing is
not implemented yet, so each present value must use the exact canonical path
shown above; VALIDATE, BUILD, and TEST reject alternatives such as
"stages": "custom-stages/" instead of silently omitting that content.
tools lands CLI scripts in the harness tools/ dir so a plugin can ship a
runnable sensor (its manifest in sensors/ + its script in tools/) and an
optional doctor check.
Keep tests and fixtures in the plugin's top-level tests/ directory, never
inside tools/. Compose drops files under tools/tests/, tools/__tests__/,
or tools/fixtures/, plus co-located *.test.ts and *.spec.ts files, and
records an advisory drop surfaced by /aidlc --doctor. It also scans the
installed tools tree for payloads left by older compose versions; because those
legacy files carry no provenance, that migration advisory names the installed
path without attributing it to the plugin currently composing.
overlays is special: its canonical directory is
contributions/, whose files are consumed by the merge rather than copied as a
primitive subtree.
memory projection remains deferred. Do not declare contributes.memory yet:
the authoring tools reject it until the default-space method-seed merge ships.
Ship only the keys your plugin uses. test-pro ships a support agent, a plugin
scope, and per-agent methodology knowledge; it still reuses
aidlc-quality-agent as the lead.
No number ranges. Stage numbers are display-only, so a plugin does not claim a number range in its manifest. See §2.
2. Add a new stage
A plugin stage is an ordinary stage file (see Anatomy of a Stage) with two extra rules:
- Its
plugin:field names your plugin. - Any artifact it
produces:must be prefixed<plugin>-(e.g.test-pro-integration-test-results).
The same logical plugin name must appear in every owned stage, scope, agent,
and contribution. Compose takes that identity from the plugin's own manifest,
which every build carries; content cannot rename or impersonate its package. A
mismatch is skipped and recorded for /aidlc --doctor.
bundle: was the pre-rename ownership key and is rejected with an error naming the fix - write plugin:. The word is reserved for a possible future collection-of-plugins concept.
Stage identity is the slug, everywhere that matters (edges, jumps,
resolution). The number: is a display hint only — a stage's graph
position comes from its slug-based requires_stage edges, and the compiled
number values are assigned by the ENGINE, never by you: on first compile,
your plugin's new stages are ordered by their own requires_stage edges,
using your authored number: values only to break ties among independent
stages, and given the next free indices in their phase. So author numbers
that read sensibly and agree with your edges (test-pro-integration is
3.85, after build-and-test at 3.6) — the RELATIVE order is what
carries — but the absolute values never land in the graph, inserting a stage
never renumbers core, and you claim no range (which is why two uncoordinated
plugins can never collide on numbers).
Gate a stage onto a scope with scopes: (it is SKIP everywhere else), and
optionally declare a when: predicate. test-pro-full-suite is intended to run
only when its upstream producer is on the plan:
scopes:
- enterprise
when:
producer-in-plan: test-pro-regression-suite
when:is parsed but not yet evaluated. The schema validates the predicate and the parser reads it, but no engine consumer acts on it today — a stage carryingwhen:is EXECUTE under its declaredscopes:unconditionally. Author it for forward-compatibility, but gate real behavior onscopes:for now.
See Scopes for scope membership and the when: predicate.
Give the stage a composer prior with ars: so /aidlc compose can screen
it mechanically. The shipped tools/data/ars-priors.json names only core
stages; without this block your stage shows up in aidlc-graph ars output as a
no-prior row the composer can only decide by judgment:
ars:
targets: [ve, r] # the ARS components the stage reduces: iae | csu | ve | r | ua
cost: 4 # 1 (trivial) .. 5 (heavy); null = never numerically screened
Two optional keys complete the entry. role: changes how the stage is
screened: initialization and core always run, structural defaults to SKIP
and is left to judgment at the gate, and phase-gate runs only when other work
in its phase runs. project_types: [brownfield] mirrors a condition: that
restricts the stage to one kind of project. The schema
validates the block like a priors-file entry and compile copies it onto the
graph node. A shipped priors entry always wins, so the block only matters on
your own stages. See Stage Definition
§ ars.
3. Modify an existing core stage (a contribution)
This is the contribution seam — additively change a core stage without editing
it. A contribution lives at
<plugin>/contributions/<phase>/<slug>.md, exactly one directory below
contributions/: a file placed higher or nested deeper is never read, so the
validator reports it and compose drop-logs it. Here is test-pro's contribution
to nfr-requirements:
---
target: nfr-requirements # the existing core stage you're enriching
plugin: test-pro
adds: # STRUCTURAL — set-unioned into the stage node
produces:
- test-pro-testability-requirements # <plugin>- prefixed
required_sections:
- "Testability Requirements" # declared; not enforced today (below)
- "Coverage Targets"
fragments: # PROSE — spliced into the stage body
- anchor: after-step:6
order: 100
---
## fragment: after-step:6
### Step 6b (test-pro): Capture testability NFRs
…prose the agent will see, appended after the target stage's Step 6…
What you can add (all additive — no override or removal, by design). "Status" marks what the compose hook merges today vs. designed-but-deferred (mirrors doc 18 §5/§8 — implement or demote, never a silent no-op):
adds.produces/adds.consumes/adds.sensors— ✅ set-unioned into the target stage's source frontmatter.adds.required_sections— ✅ merged into the stage'srequired_sections. Note it is not machine-enforced today: the field is written and validates, but it does not reach the compiled graph node, and the shippedrequired-sectionssensor derives its expectations from templates, so nothing yet fails a stage for a missing section. Treat it as declarative intent for now.adds.scopes— ✅ set-unioned into the target stage'sscopes:list, with two guard rails (each violation dropped-with-log, never merged): the scope's identity file must be installed (scopes/<name>.mdships in the same plugin), and that file'splugin:frontmatter must name YOUR plugin exactly — you cannot put a core stage under a core or foreign-plugin scope, and ownership is read from the installed file's declared owner, not inferred from a name prefix. Use it to route existing core stages under your plugin's scope — e.g. a methodology plugin whose scope carries its own discovery stages plus core Inception onward.adds.requires_stage— ✅ set-unions ordering edges into the target'srequires_stage. The dependency must already be installed and must compile before the target: a lower stage number (a pinned stage keeps its number even if it moved phase), or an earlier phase for a stage not yet numbered. Use it for the edge the stage-definition guide asks for — "I consume X, which stage Y produces → require Y" — or for cross-phase ordering. A stage your plugin adds seeds past its phase max, so a core stage cannot be made to require it within the same phase (that is RFC #1100); such an entry, an unknown slug, a self-edge, or an edge that would close a cycle among your new stages is dropped-with-log, never merged. Merged edges are re-checked on every compose and runtime upgrade: one that no longer holds is removed, and one the new core declares itself becomes core's; both leave your plugin's contribution record.fragments— ✅ prose blocks spliced into the stage body. Each fragment's prose is the## fragment: <anchor>block in the contribution file.
Fragment anchors
| Anchor | Inserts the fragment… | Status |
|---|---|---|
after-step:<n> |
right after ### Step <n> (before the next ###/##) |
✅ |
before-step:<n> |
immediately before ### Step <n> |
✅ |
end-of-steps |
at the end of the ## Steps block |
✅ |
in:<Compartment> |
at the end of the named ## <Compartment> block (e.g. in:Sensors) |
✅ |
after-preflight |
personas only: right after the delegated-knowledge preflight block | ✅ |
end-of-body |
at the end of the authored body (before absorbed reviewer knowledge) | ✅ |
after-questions |
after the questions-generating step | ⏳ not implemented — locateAnchor has no case; drops "unknown anchor". Use after-step:<n>. |
Fragments are ordered deterministically by (order, plugin). A same
(plugin, anchor, order) collision — within one file or across two contribution
files this run — is dropped-with-log (not last-writer-wins). When two
different plugins contribute to the same stage, their structural additions
set-union and their fragments interleave by this same ordering — genuinely merged.
Each spliced fragment is wrapped in a sentinel comment carrying a content hash
(<!-- plugin:<plugin>:<anchor>:<order>:<hash> --> … <!-- /plugin:… -->), which
is how re-composing stays idempotent and an upgraded fragment replaces its prior
block. Compose also records each successfully applied fragment's anchor, order,
and hash in the plugin contribution sidecar. That provenance exists even for a
prose-only plugin, allowing doctor to detect a missing marker or changed fragment
body after an engine reinstall. Two authoring rules follow from that:
- A marker-lookalike line in fragment prose is safe. The closing marker
carries the block's content hash, so a
<!-- /plugin:... -->line inside your prose (which lacks that hash) is never taken for the end of the block. - An install with hashless markers: a block whose markers carry no hash (composed before the hash was added) is not recognized on upgrade, and a second copy is spliced in. Recompose from a clean base, or delete the old block by hand, once.
Contribute to a core persona
A contribution under contributions/agents/<agent>.md targets a core persona
instead of a stage — target: is the agent slug (the file stem of
<harness>/agents/<agent>.md). Only the personas in the engine's core agent
roster qualify: any other one, such as a persona a plugin ships, is refused by
the validator and dropped-with-log by compose. Personas take prose fragments
only: their frontmatter is identity and tier, so an adds: block is refused
with an advisory drop. The anchors are in:<H2> (in:Collaboration for a new
collaboration bullet), after-preflight (right after the delegated-knowledge
preflight the packager injects, the place for a mandatory instruction every
dispatched worker must see) and end-of-body.
---
target: aidlc-quality-agent
plugin: test-pro
fragments:
- anchor: in:Collaboration
order: 100
---
## fragment: in:Collaboration
- **Works with (test-pro)**: test-pro-metrics-agent (…)
Fragments splice, record into the sidecar and strip on disable exactly like stage fragments. They reach the agent on every harness: compose writes them into the Markdown persona and into the native agent file a harness builds from it (the Codex agent TOML, the OpenCode and Copilot native agents), and Kiro CLI's agent JSON loads its prompt from the Markdown persona.
Engine upgrade lifecycle
An engine reinstall copies the stock dist/<harness>/ graph and core stage
sources over the effective install. Plugin-namespaced files and contribution
sidecars can survive that overlay while their graph entries and structural or
prose contribution merges disappear. Authors should make re-composition part of
their upgrade instructions: run /aidlc plugin sync after every engine
reinstall or upgrade (or start a new session on a host with the plugin compose
hook). Composition is idempotent, so this restores the same effective surface
without duplicating unchanged contributions. /aidlc --doctor reports the
broken state as Composed plugin surface. The check fails closed when an
enabled plugin's sidecar is unreadable or malformed, a recorded target stage no
longer exists, or a recorded structural or prose contribution is absent or
changed. Consume records preserve and verify artifact, required, and optional
conditional_on; artifact-only records from older sidecars remain compatible. An
invalid sidecar cannot be reconstructed safely from an already-composed stage:
refresh the stock engine, remove that sidecar, then run plugin sync.
Plugin upgrade lifecycle
Re-composing a newer version of your plugin (a new session on a host with the
compose hook, aidlc engine plugin sync, or bun <plugin-root>/hooks/compose.ts)
takes your newer stages, scopes, agents, knowledge, sensors, and tools wherever
the installed copy is still the one your plugin installed. Compose proves that
with tools/data/plugin-owned-<key>.json, the same hash-proven record
plugin sync writes. A copy the person changed stays as it is; the drops log
(shown by /aidlc --doctor) names the file and the step: move the change
elsewhere, remove the file, re-run compose. A project composed before that record
existed (no tools/data/plugin-owned-<key>.json) reports each differing file
the same way; identical files are recorded on the first run of the current
compose, so later upgrades need nothing. A prose fragment your new version no
longer ships at its old (anchor, order) is removed together with its sidecar
record, so a moved fragment appears once. Structural additions (adds.*) your
new version no longer declares are not removed by compose alone; plugin sync
strips them before it re-composes.
4. Packaging the other primitives
test-pro ships stages, contributions, sensors, a support agent, a scope, and
methodology knowledge. A richer plugin may also add method/rules later; memory
projection remains deferred (doc 18 §9 Status).
- Agents. Drop
agents/<plugin>-<role>-agent.mdwithplugin:set. The plugin prefix replaces core'saidlc-filename prefix, and the filename stem must equal frontmattername(for example,agents/test-pro-metrics-agent.mdhasname: test-pro-metrics-agent). It is discovered automatically after compose, and your plugin's stages may name it aslead_agent/support_agents. A same-path collision with different content is not overwritten (compose records a drop log), unless the installed file is your plugin's own earlier copy, unchanged since compose installed it: a re-compose takes your newer copy. OpenCode composition also creates the native.opencode/agents/subagent twin and denies nestedtaskdelegation. See Adding an Agent. On Kiro CLI, a natively dispatched plugin roster worker's hand-authored agent-v1 JSON must also includefile://aidlc/active-memory/**/*.md(the engine's copy of the active space's memory, the same path on every install) in itsresourcesarray, resolving to at least one existing Markdown file, because plugin workers receive the same active-stage rules as core workers. - Sensors. Ship the manifest
sensors/aidlc-<id>.mdand its script undertools/(both — a manifest alone is discoverable but its script must live intools/to run). Theaidlc-<id>.mdname at the top ofsensors/is a hard requirement, not a convention: sensor discovery flatly scanssensors/and indexes only basenames matchingaidlc-<id>.md, so a manifest under any other name (or one nested in a subdirectory) would compose but never fire. Compose now rejects such a manifest with a degraded drop (surfaced by--doctor) that names the file and the required shape, rather than letting it land dead. Bind the sensor to your own stages viasensors:, or to a core stage via a contribution'sadds.sensors. See Sensors. - Method/rules. (⏳ deferred.) A future
contributes.memorysurface will mergememory/phases/<phase>.mdandmemory/{org,team,project}.mdinto the default-space method seed (aidlc/spaces/default/memory/). The packager and compose hook do not project that subtree yet, and the authoring tools reject the declaration so a build cannot report success while omitting it. Do not ship arules/dir — that path is no longer read (the rule layer moved into per-space memory). See Rules and the Loop. - Knowledge. Ship per-agent methodology knowledge under
knowledge/<agent-slug>/, projected into the framework-shipped<harness>/knowledge/tree and loaded when that agent leads or supports a stage. Note: domain/team knowledge (aidlc/spaces/<space>/knowledge/) is empty-at-bootstrap user runtime state — a plugin does not ship it. See Team Knowledge. - Scopes. A scope's identity is one file you ship under
scopes/<plugin>-<name>.md. The plugin prefix replaces core'saidlc-filename prefix, and the filename stem must equal frontmattername(for example,scopes/test-pro-validation.mdhasname: test-pro-validation). Setfreeform_default: trueto nominate a plugin scope as the fallback when the coreclassicdefault is disabled; at most one enabled scope across the selected core/plugin set may claim it, and graph compilation rejects an ambiguous set. Membership for plugin-authored stages is theirscopes:frontmatter list; a contribution'sadds.scopes(§3) adds YOUR scope to an existing core stage. A plugin built on a base runs the base's stages in its own scope withinclude_stages: [acme-1-intent-capture, acme-3-code-generation]: compile puts them in that scope's column only, and the base's stages and scopes stay as they are. It names the base's stages only.include_stagesneeds an AI-DLC release newer than 2.11.0; 2.11.0 and earlier ignore it, so there the scope runs your own stages alone. See Scopes.
Ship a doctor check
Add tools/<plugin>-doctor.ts when your plugin has install prerequisites or
composed files that /aidlc --doctor should verify. The script is optional and
runs only while the plugin is enabled. It receives AIDLC_PROJECT_DIR,
AIDLC_HARNESS_DIR, and AIDLC_PLUGIN_NAME, and must print the JSON contract
without other stdout:
Doctor discovery derives installed plugin identities from owned stage and scope
metadata and from the composition sidecars under tools/data/. A plugin whose
compose merged contributions (sensors, produces, overlays) is discoverable even
when it owns no stage or scope.
import { existsSync } from "node:fs";
import { join } from "node:path";
const root = join(
process.env.AIDLC_PROJECT_DIR ?? process.cwd(),
process.env.AIDLC_HARNESS_DIR ?? ".claude",
);
console.log(JSON.stringify({
checks: [{
pass: existsSync(join(root, "tools", "my-plugin-helper.ts")),
label: "my-plugin helper installed",
fix: "Run `bun <harness-dir>/tools/aidlc-utility.ts plugin-sync` or re-run hooks/compose.ts.",
severity: "error",
}],
}));
Omit severity for the default error behavior. Use advisory for a visible
finding that must not fail doctor. Keep the script read-only and dependency-free;
doctor bounds its runtime/output and turns script failures into diagnostic rows.
5. Distribution + install
The shipped builder emits your plugin as a real host plugin for one harness
at a time, including .claude-plugin/plugin.json,
.codex-plugin/plugin.json, Copilot's .plugin/plugin.json, and Kiro's folder
projection:
bun <tools-dir>/aidlc-plugin-build.ts <plugin-root> <harness> [outDir]
The default output is <plugin-root>/dist/<harness>/. Run it once for each
harness you publish. The repository packager uses the same emitter to build
first-party dist/plugins/<name>/<harness>/ trees, so its byte-parity guard also
guards external builds. Publish the output to a git repo with semver tags and a
marketplace.json; teams then install through the host's native commands.
The host shows your plugin under the names you chose: the plugin is the
manifest name, listed in a catalogue named by aidlc.marketplace, or by the
plugin's own name when you leave that out. The aidlc namespace is AI-DLC's own
(our plugins show as aidlc-<name> in the aidlc-plugins catalogue), so validate
and build refuse a catalogue named aidlc or starting aidlc-, in any letter
case: a second aidlc-plugins catalogue replaces ours in Claude Code. Each repository you
publish needs its own catalogue name, because a host keeps one catalogue per name.
Claude / Codex (host store)
# teams run these in their host CLI:
/plugin marketplace add <your-org>/<your-plugin-repo> # Claude
/plugin install acme-standards@acme-aidlc-plugins # Claude
codex plugin marketplace add <your-org>/<your-plugin-repo> # Codex
codex plugin add acme-standards@acme-aidlc-plugins # Codex
A SessionStart hook (bundled in the emitted plugin) calls the same
transactional sync implementation as aidlc engine plugin sync for its injected
current root. It merges the plugin's subtrees and contributions, validates the
result, compiles the stage graph + scope grid, and writes a version/source-hash
composition stamp. The orchestrator routes entirely off that compiled graph, so
a plugin stage runs the moment it is composed in - no prose or skill file to
edit.
Sync never edits the live project while composing. It copies the relevant
harness, .agents, and aidlc surfaces to staging, composes and regenerates
there, writes plugin-compose-<key>.json and hash-proven
plugin-owned-<key>.json, then commits the staged diff through the shared
transaction engine. A fault restores all files, modes, stamps, and ownership
records. --prune-missing is intentionally stricter: it requires a proved full
host inventory, --yes in automation, and unchanged owned hashes; local or
unowned bytes are refused. At a terminal it asks nothing: it names the plugins
it prunes and how to get them back (reinstall in the host, then sync), then
prunes.
Project selection
Composition installs a plugin's bytes; selection controls which installed stages, scopes, runners, and contributions are active:
aidlc engine plugin select aidlc,test-pro
aidlc engine plugin list
aidlc engine plugin sync
plugin select stages disabled-contribution removal, graph/grid compilation,
runners, and generated tables before committing one project transaction;
re-enabled contributions return on the next SessionStart sync (on Kiro CLI and
opencode, which run no compose hook, when you run
aidlc engine plugin sync --plugin-root <folder> with the plugin's folder).
It refuses to disable a plugin needed by an active workflow. The
PLUGIN_SELECTION_CHANGED audit append is part of committed validation, so an
audit failure rolls the whole selection back. plugin list is different: it
compares host inventory with composition/ownership stamps and does not change
selection.
Kiro (no store — folder-drop, then run the composer explicitly)
# Copy the Kiro projection's .kiro folder into the project; the plugin lands
# in its own folder, .kiro/aidlc-plugins/<name>/, beside any other plugin:
cp -r <projection>/.kiro <project>/
# preferred when aidlc is on PATH:
AIDLC_PLUGIN_ROOT="<project>/.kiro/aidlc-plugins/<name>" AIDLC_PROJECT_DIR="<project>" \
AIDLC_HARNESS_DIR=.kiro aidlc engine plugin sync
# fallback: run the composer explicitly:
AIDLC_PLUGIN_ROOT="<project>/.kiro/aidlc-plugins/<name>" AIDLC_PROJECT_DIR="<project>" \
AIDLC_HARNESS_DIR=.kiro bun "<project>/.kiro/aidlc-plugins/<name>/hooks/compose.ts"
# open in Kiro IDE or kiro-cli chat → /aidlc
Kiro note. Use the
kiro-ideprojection for Kiro IDE 1.x or Kiro CLI v3; its folder-drop includes aversion: "v1".kiro/hooks/aidlc-<plugin>-compose.jsonSessionStart registration that runsaidlc engine plugin sync --harness-dir .kiro --plugin-root ./.kiro/aidlc-plugins/<name>from the workspace root, or that plugin folder's cross-platformhooks/aidlc-plugin-compose.tsBun launcher whereaidlcis not installed. Thekiroprojection emits no hook registration, so run one of the explicit composer commands above. Neither projection emits the retired.kiro.hookplugin registration.
Trust
Trust is host-native — you don't build anything:
- Claude: org admin sets strictKnownMarketplaces (managed, unoverridable).
- Codex: one-time trust prompt per plugin, content-hash-pinned.
- Kiro: n/a (folder-drop, no host gate).
Concrete examples —
plugin.json,marketplace.json,managed-settings.json(the org trust config),aidlc.lock.json— are inexamples/test-pro/. See also Plugin Mechanism §9 for the full platform-team worked example.
Authoring and testing your plugin
Start from the shipped scaffold, then use three test tiers from cheapest to most realistic.
Creating your plugin
Create a deterministic minimal plugin repository:
bun <tools-dir>/aidlc-plugin-create.ts <name> [targetDir]
bun <tools-dir>/aidlc-plugin-create.ts <name> [targetDir] --json
The name must be lowercase kebab-case, must match the target directory name,
and cannot be core, aidlc, or use the reserved aidlc- prefix. Without
targetDir, output lands at ./<name>/. CREATE refuses a non-empty target and
never overwrites existing files.
The scaffold includes a schema-valid manifest, one namespaced example stage,
scope, and agent, a root README with the full authoring flow, and a tests/
README. It intentionally omits hooks/compose.ts; validation reports the
documented absence warning and BUILD injects the bundled current hook.
Validating your plugin
Run the shipped validator against the plugin repository root before building or composing:
bun <tools-dir>/aidlc-plugin-validate.ts <plugin-root>
bun <tools-dir>/aidlc-plugin-validate.ts <plugin-root> --json
bun <tools-dir>/aidlc-plugin-validate.ts <plugin-root> --with <base-root>
The tool is offline and standalone: <plugin-root> is the directory containing
.aidlc-plugin/plugin.json; no AIDLC project or framework checkout is required.
Exit 0 means valid, 1 means authoring findings, and 2 means invalid command
usage. JSON output is {valid, errors, warnings} with stable file-scoped
findings.
Validation checks:
- the manifest exists and has the documented identity, SemVer, and
aidlc.contributesshape; - every stage parses and passes the shipped stage schema, with matching slug, filename, and plugin ownership;
- scopes use
<plugin>-<name>.md, match their frontmatter identity, declare a supported depth, and parse declared keywords as a non-empty block or flow list;include_stagesnames only your base's stages (never your own or a core stage; a base's stage slugs start with its name, as compose requires), and each is a warning that it is not checked unless--with <base-root>names your checkout of the base, which must have it; - agents use
<plugin>-<role>-agent.mdand match their frontmatter identity; - no two plugin stages produce the same artifact across
producesandoptional_produces, even when no stage consumes it; - produced artifacts use the plugin-name prefix, stage bodies are non-empty,
stage agent references resolve against the bundled core plus plugin roster,
and contribution targets resolve to bundled core stage slugs. A plugin built
on a base can lead a stage with the base's agent: without the base's
checkout such an agent (
<base>-...) is a warning that it is not checked;--with <base-root>names your own checkout of the base, whose agents then count, and must be the base your manifest names. A plugin that declares no base and names a stage (requires_stage) or agent that is neither core's nor its own fails with the one step: add the plugin that ships it todependencies; - authored plugin content uses regular files and directories; symlinks under stages, scopes, agents, contributions, sensors, knowledge, tools, or hooks are rejected instead of being silently omitted or followed;
tools/contains no nestedtests/,fixtures/, or*.test.tspayloads that composition would copy into an install;- a vendored
hooks/compose.ts, when present, is byte-identical to the template bundled with the validator. Absence is valid because plugin build injects the current template.
The user-facing aidlc plugin validate and aidlc plugin build verbs delegate
to these same shipped tools. aidlc plugin create and aidlc plugin test
remain deferred to
RFC #723 §2e; invoke
their shipped Bun tools directly.
The repository test helper's validatePluginContent() delegates these shared
rules to the same tool and retains checkout-aware fixture integration.
Building your plugin
Project one validated plugin into one host-native plugin:
bun <tools-dir>/aidlc-plugin-build.ts <plugin-root> claude
bun <tools-dir>/aidlc-plugin-build.ts <plugin-root> codex ./release/codex
bun <tools-dir>/aidlc-plugin-build.ts <plugin-root> cursor --json
The builder runs validation in-process before it writes anything. Errors refuse
the build with exit 1; warnings proceed. Invalid command usage and unknown
harness names exit 2. Without outDir, output lands at
<plugin-root>/dist/<harness>/. BUILD also rejects a symlink at the output path,
inside an existing output subtree, or between the trusted build boundary and
the output. For the default output that boundary is the plugin root, so a linked
<plugin-root>/dist is refused; environmental aliases above the boundary do
not invalidate an otherwise-owned output.
The authoring flow is:
- Create a deterministic scaffold with
aidlc-plugin-create.ts. - Author the plugin-owned stages, scopes, agents, and other contributions.
- Validate the authored root offline.
- Build each harness projection you support.
- Test composition against a disposable copy of a real install.
- Publish those generated directories and marketplace metadata from your own repository.
All four tools run from a copied AIDLC tools bundle and require neither an AIDLC project nor a framework checkout.
Testing composition
Answer "does this plugin compose cleanly into my install?" without modifying that install:
bun <tools-dir>/aidlc-plugin-test.ts <plugin-root> \
--install <project-root> [--harness <name>] [--with <base-root>] [--json]
The tool validates and builds first, copies the selected install surfaces into
a temporary candidate, runs the real emitted hooks/compose.ts, recompiles the
candidate graph, verifies the plugin stages and scopes are present, and runs
compose a second time to prove idempotency. Any compose drop, graph failure,
missing plugin node, or second-pass file change exits 1. The live install is
hashed before and after and is never a compose target.
Pass --harness when the install is ambiguous, including .kiro (Kiro CLI vs
Kiro IDE) and .aidlc (Copilot vs OpenCode). For a plugin built on a base,
--with <base-root> composes your checkout of the base into the candidate
first, as sync composes bases first, so the install does not need the base. --dist <version> is reserved
until RFC #722 milestone 2 defines a released runtime-bundle channel.
- Content validation is the always-on baseline. Run
aidlc-plugin-validate.tsagainst the authored plugin root. It is fast and gives precise authoring findings, but it does not prove that packaging or composition succeeds. - Compose integration is the default CI check. Run
aidlc-plugin-test.tsagainst a real install. Inside this repository,composePluginFixture()delegates the hook subprocess/drop reader to the same shipped implementation while retaining its test-only fixture API. This tier is deterministic and exercises the actual builder and composer, but it does not launch a model-backed harness. - Live harness e2e is opt-in compatibility evidence. Call
invokeHarness()only behind the gate returned byliveGateFor(). The live gates areAIDLC_CLAUDE_SDK_LIVE,AIDLC_KIRO_ACP_LIVE,AIDLC_CODEX_EXEC_LIVE,AIDLC_COPILOT_EXEC_LIVE,AIDLC_OPENCODE_RUN_LIVE, andAIDLC_CURSOR_RUN_LIVE. Live runs prove the host can discover and invoke the composed plugin, but they need installed CLIs, credentials, and more time. An unset gate returns a skipped result, so a green test run can mean the live check did not run.
Plugin tests under plugins/<name>/tests/*.test.ts are discovered
automatically and join the integration tier. Run one plugin's tests with:
bash tests/run-tests.sh --integration --filter "plugin-<name>"
Inside this repository, this content test is the minimum copyable shape. The helper delegates the shared rules to the shipped tool:
import { expect, test } from "bun:test";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { validatePluginContent } from "../../../tests/harness/plugin-kit.ts";
const pluginRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
test("plugin content is valid", () => {
expect(validatePluginContent(pluginRoot)).toEqual([]);
});
Add a deterministic compose test when the plugin ships stages, contributions, agents, scopes, sensors, or tools:
import { expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { composePluginFixture } from "../../../tests/harness/plugin-kit.ts";
test("plugin composes into a Claude install", () => {
const fixture = composePluginFixture({
plugin: "your-plugin",
harness: "claude",
});
const graph = JSON.parse(
readFileSync(
join(fixture.projectDir, ".claude", "tools", "data", "stage-graph.json"),
"utf-8",
),
) as Array<{ slug?: string }>;
expect(graph.some((stage) => stage.slug === "your-plugin-stage")).toBe(true);
});
Rules of the road
- Number is display-only. Author a sensible
number:; claim no range; inserting a stage never renumbers core. - Artifact namespacing. Every artifact you produce is
<plugin>-prefixed; it may not collide with a core artifact or another plugin's. - Primitive names are unique. Your scopes/agents/sensors may not
collide with core or another plugin — a collision is a compose error with
attribution. (Method files are not a plugin contribution yet: the validator
rejects
contributes.memory.) - One base at most. A plugin builds on core and at most one base plugin:
"dependencies": ["core", "acme@^1.2.0"]. The base itself builds on core alone. Sync composes the base first, so your stages can order after its stages (requires_stage), read its artifacts, name its agents and run its stages in your scopes (include_stages); check those with--with <base-root>.aidlc plugin createwrites["core"], so add the base by hand: until you do, validate and build stop on a stage or agent of another plugin, which would otherwise compose only when the names happen to sort the right way. If the installed base is missing, outside your range, builds on another plugin or lacks a stage your scopes include, your plugin is not composed (a version composed before stays as it was) and the person sees one line saying why; everything else still composes. A second base, your own name, or a range npm cannot read failsaidlc plugin validate(doc 18 §8). - Additive only. Contributions add — they cannot override or remove a core stage's fields, agent, or prose. (A genuine need to change upstream behavior is a framework design decision, not a plugin concern.)
See also
- Plugin Mechanism — the normative design: manifest, composition model, the contribution seam, the install-time rationale, the hybrid distribution model, multi-tenant guards, and as-built status (all consolidated in this one chapter).
- Anatomy of a Stage, Scopes, Sensors — the building blocks a plugin composes.