Writing a bmad-loop plugin

August 19, 2026 · View on GitHub

A plugin extends the bmad-loop orchestrator without touching its core loop. It can be as simple as a settings-only data file or as complex as the bundled Unity game-engine layer. A plugin can:

  • observe, veto, or mutate the run at every lifecycle stage (a hook bus);
  • contribute settings that render in the settings TUI and persist to policy;
  • inject its own workflow sessions at defined points in the dev/review cycle.

Plugins are folder-drop: a directory with a plugin.toml manifest (plus any helper scripts) dropped under .bmad-loop/plugins/<name>/. No registration, no install step. A plugin that ships in-process Python is loaded only when you trust it by name — dropping a folder in never runs code.

Already wrote a CLI adapter profile or the old [engine] block? Same idea — declarative TOML + optional scripts, discovered and overlaid. The plugin system generalizes both.


Quick start: the smallest plugin

A data-only plugin carries one setting and zero behavior. Create .bmad-loop/plugins/hello/plugin.toml:

[plugin]
name = "hello"
version = "1.0.0"
api_version = 1
description = "Smallest possible plugin."
author = "you"

[[settings]]
key = "greeting"
type = "str"
default = "hi"
label = "Greeting"
help = "Shown in the settings UI once this plugin is enabled."

That's a complete, loadable plugin. It has no [hooks] and no [python], so it runs no code and is byte-identical to "no plugin" at run time — it exists only to contribute a setting. This is the shape the bundled example plugin ships in.

A setting-only section appears in the TUI only once the plugin is enabled:

# .bmad-loop/policy.toml
[plugins]
enabled = ["hello"]

Distribution & discovery

Plugins are discovered from three sources, overlaid in precedence order (a later same-name plugin overrides an earlier one; a new name extends the set):

SourcePathPrecedence
Builtinbmad_loop/data/plugins/<name>/plugin.tomlbase
Entry pointbmad_loop.plugins groupreserved (see below)
Project-local<project>/.bmad-loop/plugins/<name>/plugin.tomlhighest

Each plugin lives in its own directory — that directory is the plugin's {scripts} root (see {scripts} substitution), so its manifest and helper scripts sit together.

Entry points are a documented future seam. discover() already yields an entry_point source between builtin and project, but _discover_entry_points() returns nothing today — folder-drop is the only live distribution path. When pip-installable plugins land, they slot in here with no change to authors or to discovery order (importlib.metadata selectable entry points, group bmad_loop.plugins). See src/bmad_loop/plugins/loader.py.


The manifest (plugin.toml)

Every plugin is one plugin.toml. Only [plugin] name + api_version are required; every section below is optional, so a plugin opts into exactly what it needs. The manifest parses into the immutable PluginManifest (src/bmad_loop/plugins/model.py).

[plugin] — metadata

FieldTypeDefaultPurpose
namestringRequired. Plugin id; the directory name; the [plugins.<name>] key.
api_versionintRequired. Plugin-API version this manifest targets (currently 1).
versionstring0.0.0Your plugin's own version.
descriptionstring""One line; shown in tooling.
authorstring""Attribution.
priorityint0Cross-plugin ordering at a shared stage; lower runs first, then load order.
seed_fileslist[]Project-relative gitignored files to copy into each isolated worktree.
seed_globslist[]Project-relative glob patterns to expand + copy into each worktree.

seed_files / seed_globs must be project-relative (an absolute path is rejected at load). They let a plugin prime an isolated checkout with gitignored paths it needs — e.g. the Unity plugin seeds an MCP-generated skill tree.

[[settings]] — settings schema

Each entry contributes one setting. See Settings for the full reference.

[[settings]]
key = "strict"        # required; unique within the plugin
type = "bool"         # bool | int | float | str | select
default = false
label = "Strict mode" # TUI label (falls back to the key)
help = "..."          # TUI help text
# select-only:
# options = ["a", "b"]
# numeric hints (int/float):
# min = 0
# max = 10

[hooks.<stage>] — declarative (out-of-process) hooks

A shell command bound to a lifecycle stage. See Hooks.

[hooks.pre_session]
cmd = 'python3 "{scripts}/probe.py"'
timeout_sec = 120          # default 120; must be >= 1
blocking = true            # non-zero exit vetoes (defers) the unit
fail_closed = false        # default: a hook *error* (timeout/launch) fails open

[python] — in-process module (trust-gated)

[python]
module = "hooks.py"        # plugin-relative file
class = "MyPlugin"         # subclass of bmad_loop.plugins.Plugin (default "Plugin")

Declaring [python] makes the whole plugin trust-gated: the module is never imported unless the plugin is in [plugins] enabled. See Trust.

[workflows.<name>] — provided workflows

An extra agent session injected at a stage. See Workflows.

[workflows.lint-sweep]
stage = "post_dev_phase"   # post_dev_phase | post_review_result | pre_commit_gate
role = "dev"               # dev | review
prompt = "/lint-sweep {story_key}"
blocking = false           # true: a failed session defers the unit

Declare <name>_enabled / <name>_blocking settings to let operators disable a step or flip its gate per run — see Making a workflow configurable.

{scripts} substitution

In any hook cmd or workflow prompt, {scripts} expands to the plugin's own directory — so a plugin references its bundled scripts without hardcoding a path that breaks across machines or between a builtin and a project-local copy.


Trust & safety

There are two trust tiers, by design:

  1. Declarative tier (always runs). A data-only or declarative plugin — settings + [hooks.<stage>] shell commands — loads and runs as soon as it is discovered. This is the same risk surface as the old engine.toml *_cmd hooks or a project's verify commands: operator-authored shell, trusted by virtue of living in the repo.

  2. In-process tier (trust-gated). A plugin that declares a [python] module is never imported or executed unless its name is in:

    [plugins]
    enabled = ["my-plugin"]
    

    Dropping a [python] plugin folder in never runs its code. The module (and anything it provides that depends on the module — its on_<stage> handlers, its validate, and its provided workflows) stays inert until you list it. An un-enabled [python] plugin is recorded plugin-untrusted in the run journal.

Failure isolation. Every hook — subprocess or Python — is wrapped. A Python handler that raises is caught (except Exception only — RunStopped/SIGTERM as BaseException always propagate), journalled plugin-error, and the offending instance is disabled for the rest of the run. The run survives. A blocking declarative hook fails open by default (an error lets the run continue; only a clean non-zero exit vetoes); set fail_closed = true to make any failure defer the unit. An in-process handler can opt into the same by setting fail_closed = True on its class.

Versioning. Every manifest declares api_version. The framework supports a set of versions (SUPPORTED_API). A builtin with an unsupported version is a hard error (a packaging bug we shipped); a third-party one is skipped with a warning (plugin-skipped) so a stale drop-in can never take a run down.


Settings

A [[settings]] entry is presentation + validation metadata. The vocabulary matches the core settings fields exactly:

FieldApplies toMeaning
keyallUnique within the plugin; the policy + env key.
typeallbool | int | float | str | select.
defaultallValue when the operator hasn't set one.
labelallTUI label (falls back to key).
helpallTUI help text.
optionsselectNon-empty list of allowed string values.
min/maxint/floatNumeric bounds (widget hints).

Rendering. Once a plugin is enabled, its settings appear as their own section in the settings TUI (generated from the schema — see src/bmad_loop/settings_schema.py). They persist to a [plugins.<name>] table in policy.toml:

[plugins]
enabled = ["my-plugin"]

[plugins.my-plugin]
strict = true
mode = "b"

Reading a setting.

  • In an in-process plugin: self.settings["strict"] — the manifest defaults overlaid by the operator's [plugins.<name>] table.
  • In a declarative hook: each setting is exported as an environment variable BMAD_LOOP_SETTING_<KEY> (uppercased), already resolved.
  • Anywhere with a Policy: policy.plugin_setting("my-plugin", "strict", default).

Settings are data, not code — a plugin can carry [plugins.<name>] settings without being in enabled (the settings UI just won't surface a disabled plugin's section). Only the in-process [python] module is trust-gated.


Hooks

A hook binds a stage to behavior. The hook bus (src/bmad_loop/plugins/bus.py) fans each stage out to every bound plugin, in registry order (priority, then load order). A run with no plugin bound to a stage does no work for it (an O(1) fast-path) — zero-plugin runs stay byte-identical.

Declarative hooks

A [hooks.<stage>] shell command. The bus runs it with:

  • cwd = the unit's worktree (or repo root);

  • a BMAD_LOOP_* environment describing the run:

    VarMeaning
    BMAD_LOOP_STAGEthe stage firing
    BMAD_LOOP_RUN_ID / BMAD_LOOP_RUN_DIRrun identity
    BMAD_LOOP_REPO_ROOT / BMAD_LOOP_WORKTREEgit roots
    BMAD_LOOP_STORY_KEY / BMAD_LOOP_ROLE / BMAD_LOOP_PHASE / BMAD_LOOP_BRANCHunit context
    BMAD_LOOP_AGENTScomma-separated CLI agent ids in the worktree
    BMAD_LOOP_PLUGINyour plugin's name
    BMAD_LOOP_SETTING_<KEY>each resolved setting

A blocking hook's non-zero exit vetoes (defers) the unit. A non-blocking hook is advisory (logged plugin-hook).

Mutating from a declarative hook. Emit a single JSON object on the last non-empty stdout line:

{
  "shared": { "scanned": 42 },
  "mutate": { "proposed_commit_message": "rewritten by my-plugin" },
  "veto": { "action": "defer", "reason": "lint failed" }
}
  • shared — merged into the cross-stage shared dict.
  • mutate — only whitelisted fields for the current stage.
  • vetoactionskip | defer | pause. Supplying a veto replaces the implicit "non-zero exit = defer".

Any non-JSON output is treated as advisory log text.

In-process hooks

Subclass bmad_loop.plugins.Plugin and define on_<stage>(self, ctx) methods. The bus calls the handler for each stage you implement; you only mark the stages you handle, so the fast path holds for the rest.

from bmad_loop.plugins import Plugin

class MyPlugin(Plugin):
    fail_closed = False   # a raised handler is isolated; True also defers the unit

    def on_pre_commit(self, ctx):
        ctx.proposed_commit_message = f"{ctx.proposed_commit_message}\n\nShipped-by: me"

Optionally override validate(self, policy) to reject an incompatible config at startup (raise PluginError) — e.g. a coupling between a plugin setting and a core policy field. This runs once before any stage; a raise fails the run fast (it is a deliberate config rejection, not isolated like a hook bug).

Don't do expensive or side-effecting work in __init__. Construction happens at registry-build time, and a raised exception there disables the instance.

The HookContext

Every hook receives one HookContext (src/bmad_loop/plugins/context.py) for the stage. It carries:

  • Read-only facts (properties, no setter): run_id, story_key, epic, phase, attempt, role, worktree, branch, repo_root, run_dir, agents, result_json (a copy), session_status, verify_reason, decision_action, settings. Observe these; you can never rewrite history.
  • A mutable whitelist — assign only these, and only where the stage allows: proposed_prompt, proposed_env, proposed_feedback, proposed_commit_message, proposed_decision.
  • ctx.shared — a free-form, JSON-serializable dict that persists across stages (the engine backs it with RunState.plugin_shared, so it survives pause/resume). Use it to carry state between your own hooks.

Veto is ctx.veto(action, reason), with action:

ActionRoutes onto the engine's existing…Effect
skipquiet retireunit dropped (DEFERRED), no notification
deferdefer primitiveunit deferred + operator notified
pauseescalationrun pauses (raises RunPaused)

There is no new abort path — a veto maps onto control flow the engine already has. Multiple plugins can object; the bus collects every veto without short-circuit and resolves the most-conservative one (pause > defer > skip), so load order can never hide a severer objection. A post_*-stage veto is clamp-conservative only: a plugin can escalate the engine's own decision, never silence it.


Stage reference

Stages fire in pre_/post_ pairs around each unit of work. post_* stages see the mutations earlier pre_* stages made. The mutable surface column lists what a hook may assign at that stage; everything else on the context is read-only there.

Run / loop (no unit — a pause veto pauses the run)

StageWhen
pre_run / post_runaround the whole run
pre_pick_next / post_pick_nextaround selecting the next story
pre_epic_boundary / post_epic_boundaryat an epic transition

Story / unit

StageWhenMutable surface
pre_story / post_storyaround one storyveto (pre_)
pre_worktree_setup / post_worktree_setuparound isolated-worktree provisioning
pre_ready_gate / post_ready_gatearound the engine-ready gateveto (pre_)
pre_worktree_teardown / post_worktree_teardownaround teardown (in a finally)observe-only — a veto here cannot un-tear-down
pre_rollback / post_rollbackaround a failed attempt's git reset --hard (only when a rollback actually runs)observe-only — a veto here cannot block the reset
pre_integratebefore integrating a finished unit
pre_merge / post_mergearound the local branch merge

Dev

StageWhenMutable surface
pre_dev_phase / post_dev_phasearound the dev attempt loopveto (pre_); post_dev_phase is a workflow injection point
pre_dev_sessionbefore each dev sessionproposed_prompt, proposed_env, veto
post_dev_verifyafter dev or repair verification

post_dev_verify fires on both legs of the dev phase — once after the dev session's verification, and again after each repair session's — so a handler sees it more than once per story, not once. The two legs share one attempt counter bounded by [limits] max_dev_attempts, which is also the bound on how many times the stage can fire for one story. Write handlers to be idempotent and to key on the correlation fields below rather than on the story alone. It also fires on the way to a pause: an attempt whose session reported a CRITICAL escalation emits before the run stops, on either leg.

post_dev_verify exposes ctx.command_results: an immutable tuple of the per-command CommandResult records core just executed. Each has command, returncode, the existing merged bounded output_tail, and separate stdout and stderr strings. Those two are intended to be the streams essentially whole — they are not cut to [verify] stream_capture_kb, which bounds only what is written to disk — but they are not unbounded either: a hard 32 MiB per-stream ceiling applies, so a pathologically chatty command cannot grow the orchestrator's peak memory with the number of configured verify commands. When that ceiling cuts a stream the tail is what a plugin receives, and the matching journal record's stdout_bytes / stderr_bytes still report what the command emitted, so the cut is always detectable rather than silent. Ordinary suites never reach it. This is observation data only: a plugin cannot change the verifier's outcome or the commit decision. The run's journal.jsonl also records one verify-command-result entry per command with run/story/attempt/stage and verification-sequence correlation, output_tail, byte counts, and run-relative stdout_path / stderr_path pointers under the run's verify/ directory; full streams are not embedded in the journal. That store is deliberately separate from logs/, which holds coding-CLI pane captures named after session task ids and is read as such by the TUI.

Two more context fields say which verification the results came from, because nothing else on the context can: both the dev leg and the repair leg emit post_dev_verify from Phase.DEV_VERIFY, and ctx.attempt is one per-story counter the repair leg continues rather than restarts, so its value orders the two but names neither.

FieldValue
ctx.verification_stage"dev" for the initial dev verification, "fix" for a repair one, None if none ran
ctx.verification_sequencethe story's 1-based ordinal for that pass, or None if it recorded nothing

Together they are the join key: the verify-command-result entries carrying this story_key + verification_stage + verification_sequence are exactly this context's results, one per record, ordered by command_index. The sequence is monotonic per story across a pause/resume — unlike attempt, which a human re-arm reuses — so it is safe to persist as a correlation id.

Read ctx.command_results == () together with verification_stage; on its own it is ambiguous:

  • verification_stage is None — no verify pass ran. Several causes land here and the empty tuple names none of them: the session did not complete (ctx.session_status), an earlier gate already failed the attempt — the dev-artifact check or the deferral harvest (ctx.verify_reason) — or the engine variant suppressed the pass for this leg (stories mode skips it on a plan-halt leg, which has no implementation to build).
  • stage set, verification_sequence is None — the pass ran and executed nothing, because [verify] commands is empty. No journal record exists either.
  • stage set, sequence an int — those commands ran, and each has a matching journal record.

What lands on disk is bounded by [verify] stream_capture_kb (default 256 KiB per stream): the tail is retained, and the record stays explicit about the cut — stdout_bytes / stderr_bytes are what the command emitted, stdout_captured_bytes / stderr_captured_bytes how much of that reached disk, and stdout_truncated / stderr_truncated their inequality. Both counts are UTF-8 lengths of the decoded stream, not file sizes: the files are written in text mode, so Windows newline translation makes the file larger there. Set the knob to 0 to retain nothing at all — no files are written and the pointers are null, but the record still lands with the full byte counts, because "nothing was retained" and "the command was silent" are different facts. Retaining is observation and never fails a run: if the write raises (ENOSPC, a read-only run dir, or a verify/ directory whose confinement cannot be established — the store refuses rather than write through a symlink a session planted), the pointer is null and capture_error carries the reason. A plugin reading these pointers must therefore treat both None and a missing file as normal, and consult *_truncated before assuming a file holds a command's whole output. Treat verifier output as potentially sensitive and store, upload, sign, or act on it only from an explicitly configured plugin.

The dev phase is the whole of this surface. [verify] commands also run at the review gate — verify_review / verify_review_stories / verify_review_bundle end on the same core classifier — and none of those runs are journalled or published to any hook. Five engine gates reach them: the converged review pass, the review-budget-exhaustion rescue, the review-timeout salvage, and both passes inside the skip-review commit path (which runs the gate again after a repair). bmad-loop confirm --reverify runs the commands too, out of band by construction — the run that parked the story is finished, so there is no journal to write to and no hook bus to emit on.

Two consequences a handler has to be written for:

  • verify-command-result entries are not a complete census of a run's verifier invocations. Every story that reaches a commit ran the commands at least once more than the records show. Never derive "the verifier ran N times" or "the last thing the verifier saw" from the journal — derive only "these are the dev-phase passes", which is what the records claim.
  • A green commit is not evidence that the last journalled pass was green, and a red journalled pass is not evidence the commit was blocked: a fix pass can fail and the story still commit after a later review-gate run that left no record. Correlate a decision with the dev-decision / fix-decision / review-result entries beside the results, not with the results alone.

The boundary is deliberate, not an oversight — the review leg would need its own hook stage rather than a second meaning for one named post_dev_verify — and is tracked as a follow-up in #656.

Review

StageWhenMutable surface
pre_review_phasebefore the review loopveto
pre_review_sessionbefore each review sessionproposed_prompt, proposed_env, veto
post_review_sessionafter each review session
post_review_resultafter a review verdicta workflow injection point
pre_fix_sessionbefore a verify-repair sessionproposed_prompt, proposed_env, veto

None of these carries the review gate's [verify] commands results — that gate runs the commands and retains nothing. See the boundary note above ### Review.

Commit

StageWhenMutable surface
pre_commit_gatejust before every commit, on every patha workflow injection point; defer-safe (the unit may still defer)
pre_commitbefore committing (after pre_commit_gate)proposed_commit_message; only a pause veto is honored (the unit is mid-commit)
post_commitafter committing

Generic session boundary

StageWhenMutable surface
pre_sessionbefore every session (after the role-specific pre_*_session, so it sees its mutations)proposed_prompt, proposed_env, veto
post_sessionafter every session

Sweep (deferred-work cycle — bundles inherit all per-story stages)

StageWhen
pre_sweep_cycle / post_sweep_cyclearound a sweep cycle
pre_triage_session / post_triagearound triage
pre_migrate_session / post_migratearound a legacy-ledger migration
pre_close_resolved / post_close_resolvedaround closing resolved entries
pre_decision / post_decisionaround a human-decision item
pre_bundle / post_bundlearound a deferred-work bundle
pre_materialize_bundles / post_materialize_bundlesaround materializing bundles

Workflows ([provides])

A workflow is the conservative form of custom orchestration: an extra agent session injected at a lifecycle stage, run through the engine's normal session machinery — no new pipeline stage. It is the right tool when you want an additional pass (a doc sync, a lint sweep, an extra reviewer) without rewriting the loop.

[workflows.doc-sync]
stage = "post_dev_phase"   # injection point (see below)
role = "review"            # which adapter runs it: dev | review
prompt = "Update CHANGELOG.md for story {story_key} if it introduced user-facing changes."
blocking = false           # true: a non-completed session defers the unit
  • Injection stages are deliberately limited to where the unit's worktree is live and the dev/review work is on disk: post_dev_phase (right after dev lands), post_review_result (after a review verdict — fires only when the orchestrator review loop actually runs; review.trigger = "recommended" skips it on stories whose dev session recommends no follow-up), and pre_commit_gate (unconditionally just before every commit — review, skip, and budget-rescue paths alike — so a session there evaluates the exact tree about to commit). pre_commit_gate is defer-safe: it fires before the unit enters COMMITTING, so a blocking workflow whose session doesn't complete still defers cleanly. Other stages lack a worktree or run after teardown.
  • prompt expands {story_key}, {run_id}, and {scripts}.
  • The orchestrator appends two sections to what you write, and appends them after the session gates fire, so a pre_workflow_session / pre_session prompt rewrite cannot strip them: a ## Sprint board prohibition — the same one dev and review prompts carry, because a workflow session runs while the orchestrator's own sprint-status.yaml advance is still uncommitted and must not be "fixed" or reverted (#437) — and the ## Completion signal contract naming the marker file the session must write before ending its turn. Write prompt as a self-contained instruction; do not restate either. (Stories mode has no board, so the first section is absent there.)
  • The injected session is a first-class session: it fires pre_workflow_sessionpre_sessionpost_session, is recorded on the task, and counts toward the token budget. Its journal entries are workflow-start / workflow-end.
  • blocking: a blocking workflow whose session doesn't complete defers the unit (through the existing defer primitive). A non-blocking one is advisory.
  • A workflow from a [python] plugin is trust-gated along with the module: it fires only when the plugin is enabled. A workflow from a pure-declarative plugin fires whenever the plugin is discovered.

registry.provided_workflows() lists declared workflow names for introspection.

Making a workflow configurable

The blocking and (in effect) on/off state a workflow declares in its manifest are defaults. A plugin can let an operator tune them per run — disable a step or flip its gate — by declaring settings that follow a naming convention. No Python is required; the registry reads the resolved settings when it injects.

The convention, keyed on the workflow's name:

Setting keyTypeEffect
<name>_enabledboolWhen explicitly false, the step is dropped — no session runs.
<name>_blockingboolOverrides the manifest's blocking flag for that workflow.

Default semantics — absent settings change nothing. The overlay only acts on a setting that is present and (for _enabled) explicitly false; _blocking falls back to the manifest value when unset. A plugin that declares none of these settings is byte-identical to one written before the feature existed.

Declare the matching [[settings]] so the keys are first-class (typed, documented, surfaced in the settings UI) and operators can flip them from [plugins.<plugin>] in policy.toml:

# A gate step: generated advisory by default, an operator can make it block.
[workflows.nfr]
stage = "pre_commit_gate"
role = "review"
prompt = "Run the NFR assessment for the changes in {story_key}."
blocking = false              # manifest default: advisory

[[settings]]
key = "nfr_enabled"           # <name>_enabled  -> drop the step when false
type = "bool"
default = true
help = "Run the NFR workflow after review."

[[settings]]
key = "nfr_blocking"          # <name>_blocking -> override the blocking flag
type = "bool"
default = false
help = "Escalate the unit when the NFR gate is not satisfied."

An operator then opts in from policy:

[plugins.tea]
nfr_blocking = true           # flip the advisory gate to blocking
td_enabled   = false          # turn the test-design step off entirely

Interaction with the blocking / defer path. <name>_blocking feeds the same WorkflowSpec.blocking the engine already honors: a blocking workflow whose session does not complete defers the unit through the existing defer primitive (see Workflows above). The overlay only changes which value that flag holds at injection time — it adds no new control flow. (For quality gating on a workflow's output rather than its completion, do that in an in-process on_pre_commit hook; the manifest blocking flag only checks session completion.)

Disabling every step at a stage is free. When a setting turns off the last remaining workflow at a stage, that stage drops out of registry.workflow_stages() too, so the engine's O(1) per-stage injection guard skips it entirely — the same as if no workflow had ever been declared there.


Worked walkthrough: the guardrails plugin

The repo ships a complete example under examples/plugins/guardrails/ that exercises a setting, an observe hook, a veto gate, a commit-message mutation, and a provided workflow — in ~40 lines of Python plus a manifest. Build it yourself:

1. Make the folder. In your project:

.bmad-loop/plugins/guardrails/
  plugin.toml
  guardrails.py

2. Write the manifest (plugin.toml): metadata, a [python] module, two [[settings]] (trailer, forbid_epic), and one [workflows.doc-sync] bound to post_dev_phase. Copy it from the example.

3. Write the module (guardrails.py):

from bmad_loop.plugins import Plugin

class GuardrailsPlugin(Plugin):
    fail_closed = False

    def on_pre_story(self, ctx):
        # observe: count stories in the cross-stage shared dict
        ctx.shared["stories_seen"] = ctx.shared.get("stories_seen", 0) + 1

    def on_pre_dev_phase(self, ctx):
        # gate: skip a "parked" epic
        parked = int(self.settings.get("forbid_epic") or 0)
        if parked and ctx.epic == parked:
            ctx.veto("skip", f"epic {parked} is parked")

    def on_pre_commit(self, ctx):
        # mutate: append a trailer to the commit message
        trailer = str(self.settings.get("trailer") or "").strip()
        if trailer and trailer not in (ctx.proposed_commit_message or ""):
            base = (ctx.proposed_commit_message or "").rstrip()
            ctx.proposed_commit_message = f"{base}\n\n{trailer}" if base else trailer

4. Enable + configure in .bmad-loop/policy.toml:

[plugins]
enabled = ["guardrails"]

[plugins.guardrails]
trailer = "Automated-by: bmad-loop"
forbid_epic = 0           # set to an epic number to park it

5. Run. On the next bmad-loop run:

  • the settings TUI shows a guardrails section with trailer + forbid_epic;
  • each story increments stories_seen in the run's plugin_shared;
  • a story in the parked epic is skipped before its dev session;
  • after dev lands, the doc-sync workflow runs an extra review-role session;
  • each commit message gets the trailer appended.

Drop [python] from the manifest and guardrails from enabled, and the plugin goes completely inert — proof of the trust gate.


Platform portability

bmad-loop's core is portable Python, and the OS-specific work is quarantined behind seams (the tmux transport, the ProcessHost lifecycle, the hook interpreter — see Porting bmad-loop to a new OS). Plugin helper scripts are spawned under the orchestrator's own interpreter (sys.executable), not a PATH-resolved python3 — so a bundled script may import bmad_loop and reach those seams instead of re-implementing OS primitives. Follow this discipline:

  • Use the ProcessHost seam for pid lifecycle. For terminate / force-kill / liveness, call get_process_host() rather than guarding os.kill / signal.SIGKILL / taskkill behind your own sys.platform branch:

    from bmad_loop.process_host import get_process_host
    
    host = get_process_host()
    host.terminate(pid)
    if host.is_alive(pid):
        host.force_kill(pid)
    

    The seam already carries the POSIX and Windows behavior, so the script gains a new OS for free when a host registers.

  • Stay dependency-free. Beyond bmad_loop itself, import only the stdlib so the script runs anywhere the core does. If you genuinely need a third-party package on one platform, make it an optional extra in pyproject.toml and import it lazily with a clear error if missing — never at module top level. The bundled Unity plugin does exactly this for psutil: imported only on non-Linux process discovery (a core dependency on Windows, the non-linux extra on macOS), so the dep-free Linux/WSL path never pulls it in.

  • Guard the primitives that have no seam. Anything absent or differently-shaped on Windows that isn't behind a seam — cp/--reflink, symlinks, /proc scanning, /tmp, start_new_session — still needs a fallback behind a sys.platform branch, with a # portability: <reason> comment so the intent (and the CI portability guard, tests/test_portability_guard.py) stays honest. Keep the Linux fast path byte-identical; the Windows branch can be best-effort and is not exercised until a native-Windows backend ships. WSL is Linux, so it takes the fast path unchanged.

The Unity plugin's unity_teardown.py is the worked example: it delegates its SIGTERM→SIGKILL sweep of leaked Editor / MCP-server processes to get_process_host() (so it inherits Windows behavior), while still doing its own worktree-bound process discovery via the guarded /proc-with-psutil-fallback scan. See it alongside unity_setup.py / unity_cleanup.py (and the Game Engine plugin guide) for both shapes.

Reference

  • Model + base class: src/bmad_loop/plugins/model.py
  • Manifest parser: src/bmad_loop/plugins/manifest.py
  • Discovery + overlay + api-check: src/bmad_loop/plugins/loader.py
  • Trust gate: src/bmad_loop/plugins/trust.py
  • Registry (the inter-pillar contract): src/bmad_loop/plugins/registry.py
  • Hook bus + dispatch: src/bmad_loop/plugins/bus.py
  • Hook context + veto: src/bmad_loop/plugins/context.py
  • Settings schema: src/bmad_loop/settings_schema.py
  • The bundled Unity engine plugin (a real, complex example): src/bmad_loop/data/plugins/unity/
  • Game-engine specifics: Game Engine plugin guide