loadCustomPlugins read .buddy/plugins/*.json, called JSON.parse, and pushed the result straight onto the plugin list:
const pluginConfig = JSON.parse(fs.readFileSync(path.join('.buddy/plugins/', file), 'utf8'))
plugins.push(pluginConfig)SetupHook.handler is typed (context: SetupContext) => Promise<void> | void. JSON has no way to carry a function.
So every custom plugin loaded, announced itself with 🔌 Loaded plugin, and then failed on each hook with hook.handler is not a function — caught by the hook runner and logged as an ordinary hook failure.
What it was meant to be
The documented example was never executable, and didn't pretend otherwise:
"handler": "// Custom JavaScript function"Its configuration block held webhook_url and api_key, and all three built-in plugins post webhooks. So what a JSON plugin was for isn't in doubt. Hooks now declare an action:
"action": { "type": "webhook", "url": "https://…", "body": { "channel": "#builds" } }Declarative rather than executable, deliberately. Reading a string out of a config file and running it would turn any .buddy/plugins/*.json committed to a repository into arbitrary code execution on whoever runs buddy setup.
handler stays for the built-in plugins, which are TypeScript and supply a real function. A hook with neither is skipped with a warning rather than a TypeError.
Validation and payload
Plugin files are validated as they load, and a rejection names the file and the problem. A plugin still written the old way is told what to write instead:
⚠️ Ignoring plugin custom.json: hook "notify" declares a handler as text. JSON cannot carry a function — use
"action": { "type": "webhook", "url": "..." }instead.
The payload is a fixed shape — the event and the repository, plus whatever body adds. Setup is holding a token by the time hooks run, and the URL is chosen by a file in the repository, so nothing else on the context goes out. There's a test asserting the token can't appear in it.
Why it survived
None of this had coverage. test/plugin-architecture.test.ts builds a plugin object, calls loadPlugin, then asserts on the object it just built:
// Create custom plugin configuration (without handler function since it can't be serialized)
...
expect(customPlugin.name).toBe('custom-integration')That comment is the defect — worked around rather than surfaced. loadCustomPlugins was never called by a test at all. It is now, against real files on disk.
Docs
Corrected in README.md, in docs/guide/configuration.md — which showed a plugin with no hooks key whatsoever — and in CLAUDE.md, whose embedded copy is auto-loaded as project instructions, so a wrong example there misleads every future run.
Tests
2034 → 2047.
Lint ✓ · typecheck ✓ · check:docs ✓ · 2047 pass / 0 fail
🤖 Generated with Claude Code