Plugin Development Guide
Plugin Development Guide
Section titled “Plugin Development Guide”Plugins extend agent-detective to connect any event source (Jira, Telegram, Slack, etc.). This guide covers the plugin APIs and patterns for authors. For where to point config.plugins[].package and how to use npm or filesystem paths for custom plugins, see extending-with-plugins.md first.
Bundled plugin options (Zod → JSON Schema): after changing the Zod options schema in @agent-detective/jira-adapter, @agent-detective/linear-adapter, @agent-detective/local-repos-plugin, or @agent-detective/pr-pipeline (packages/*/src/application/options-schema.ts), run pnpm docs:plugins and commit generated/plugin-options.md. See configuration.md.
Table of Contents
Section titled “Table of Contents”- Plugin Anatomy
- Core Context Reference
2b. Task queue (
TaskQueue) - Schema System
- TaskEvent Interface
- Example: Jira-Style Webhook Plugin
- Example: Interactive Question Plugin (Telegram-Style)
- Example: Slash Command Plugin
- Example: Polling Plugin
- Publishing a Plugin as an npm Package
- Core API Reference
- Error Handling
- Testing Patterns
- Third-Party Plugins
- Official Bundled Plugins
1. Plugin Anatomy
Section titled “1. Plugin Anatomy”A plugin is an ES module that exports a plain object with the following structure:
import { definePlugin, type PluginContext } from '@agent-detective/sdk';
export default definePlugin({ name: '@myorg/my-adapter', // unique package name version: '1.0.0', // semver version schemaVersion: '1.0', // must be '1.0'
schema: { // JSON Schema for config validation type: 'object', properties: { enabled: { type: 'boolean', default: true }, webhookPath: { type: 'string', default: '/webhook/my-plugin' }, }, required: [] },
register(scope, context: PluginContext) { // scope: encapsulated Fastify instance mounted at /plugins/{sanitized-name} // context: core dependencies (see Section 2) }});Required Fields
Section titled “Required Fields”| Field | Type | Description |
|---|---|---|
name | string | Unique plugin identifier (e.g., @agent-detective/jira-adapter) |
version | string | Semver version (e.g., 1.0.0) |
register | function | Called on load with (scope, context), where scope is a Fastify instance encapsulated under /plugins/{sanitized-name} |
schemaVersion | string | Must be '1.0' |
Optional Fields
Section titled “Optional Fields”| Field | Type | Description |
|---|---|---|
schema | object | JSON Schema for config validation (see Section 3) |
2. Core Context Reference
Section titled “2. Core Context Reference”The register function receives a context object with all core services:
register(scope, context: PluginContext) { const { agentRunner, // run AI agents config, // validated plugin config with defaults applied logger, // logger with plugin prefix } = context;
// Accessing repository context from local-repos-plugin try { const localReposService = context.getService<LocalReposService>('@agent-detective/local-repos-plugin'); const localRepos = localReposService.localRepos; // ... } catch (err) { logger.warn('Local repos service not available'); }}Available Context Members
Section titled “Available Context Members”| Member | Type | Description |
|---|---|---|
agentRunner | AgentRunner | Executes AI agent prompts (see Section 10) |
config | object | Plugin config validated against schema, with defaults merged |
logger | Logger | Logger with .info(), .warn(), .error() |
registerService<T>(name, service) | function | Register a service for other plugins to consume |
getService<T>(name) | function | Get a registered service by name with type safety |
getServiceFromPlugin<T>(name, providerPluginName) | function | Get a service from a specific provider plugin |
registerCapability(name) | function | Register a capability provided by this plugin |
hasCapability(name) | function | Check if a capability is registered |
registerAgent(agent) | function | Register a new AI agent provider |
registerTaskQueue(queue) | function | Replace the global TaskQueue backend (same contract as enqueue; use for Redis/SQLite workers) |
enqueue | function | Enqueue tasks to be executed sequentially per key (delegates to the active queue) |
Task queue (TaskQueue)
Section titled “Task queue (TaskQueue)”The host builds the plugin system before the orchestrator and HTTP API, and passes createPluginSystem(...).enqueue into those components so every code path shares one queue.
createPluginSystem({ agentRunner, events, logger?, taskQueue? }): iftaskQueueis omitted, the core uses an in-memoryTaskQueue(same behavior as before). PasstaskQueuefrom tests or from a custom bootstrap if you need a specific initial backend.- Plugins call
context.registerTaskQueue(queue)to replace the backend at runtime (for example a Redis-backed package loaded viaconfig.plugins). The previous queue’s optionalshutdown()is invoked (async errors are logged). EnqueueFnis still in-process:enqueue(key, fn)runs the givenfnin this Node process. Surviving process restarts requires a plugin that persists serialized work and replays it (a different design than only swappingTaskQueue).
There is no enqueue option on createPluginSystem; use taskQueue or the default memory queue.
Type-Safe Service Registry
Section titled “Type-Safe Service Registry”Plugins can share functionality by registering services. This is preferred over accessing the plugins dictionary directly as it provides better type safety and error handling.
Capabilities (registerCapability, requiresCapabilities)
Section titled “Capabilities (registerCapability, requiresCapabilities)”Capabilities are a standardized feature vocabulary that answers: “is some loaded plugin providing this feature?”\n\n- Use dependsOn when you require a specific plugin to be loaded first (usually because you call getService(...) for a service it registers).\n- Use requiresCapabilities when you only care that someone provides a feature, regardless of which plugin.\n- Prefer SDK-owned constants from @agent-detective/sdk (StandardCapabilities.*) over ad-hoc strings.\n- Third-party plugins that define custom capabilities should use stable, namespaced strings (e.g. acme.example/repo-matching) to avoid collisions.\n\nWhen multiple plugins register the same capability-backed service (like CODE_ANALYSIS_SERVICE), the host selects a default provider by:\n\n- preferring first-party plugins (@agent-detective/*)\n- otherwise using the config.plugins[] order as a stable tie-break\n+\n+The host validates capability-backed contracts at boot. In strict mode it can abort startup when a plugin requires a capability but the mapped service key is missing.\n\nTo fail startup on any plugin load/config error (import failure, schema/options validation error, or register exception), enable pluginSystem.failOnPluginLoadErrors.\n
Providing a Service
Section titled “Providing a Service”// In your plugin's index.tsexport interface MyService { doSomething(): string;}
const myPlugin: Plugin = { name: 'my-provider-plugin', // ... register(app, context) { const service: MyService = { doSomething: () => 'Hello from service!' };
context.registerService<MyService>('my-service', service); }};Consuming a Service
Section titled “Consuming a Service”// In the consumer pluginimport type { MyService } from 'my-provider-plugin';
const consumerPlugin: Plugin = { name: 'my-consumer-plugin', dependsOn: ['my-provider-plugin'], register(app, context) { // getService will throw if the service is not found const myService = context.getService<MyService>('my-service');
console.log(myService.doSomething()); }};LocalReposContext
Section titled “LocalReposContext”interface LocalReposContext { repos: ValidatedRepo[]; getRepo(name: string): ValidatedRepo | null; getAllRepos(): ValidatedRepo[];}
interface ValidatedRepo { name: string; path: string; exists: boolean; description?: string; techStack: string[]; summary: string;}3. Schema System
Section titled “3. Schema System”Plugins define their configuration schema using a subset of JSON Schema. The core validates the config against the schema when the plugin loads.
Supported Property Types
Section titled “Supported Property Types”schema: { type: 'object', properties: { // String values webhookPath: { type: 'string', default: '/webhook/my' }, apiKey: { type: 'string', default: '' },
// Boolean values enabled: { type: 'boolean', default: true }, mockMode: { type: 'boolean', default: false },
// Number values timeoutMs: { type: 'number', default: 30000 }, maxRetries: { type: 'number', default: 3 },
// Array values allowedChannels: { type: 'array', default: [] },
// Object values nestedConfig: { type: 'object', default: {} }, }, required: ['webhookPath'] // fields that must be present}How Defaults Work
Section titled “How Defaults Work”The plugin system automatically merges defaults from the schema into config before calling register. You don’t need to apply defaults manually:
// schema defines: webhookPath: { type: 'string', default: '/webhook/my' }// config in default.json: { package: 'my-adapter', options: {} }
// In register, config.webhookPath will be '/webhook/my' even though// the user didn't specify it in default.jsonregister(app, { config }) { console.log(config.webhookPath); // '/webhook/my' - default was applied}Schema Version
Section titled “Schema Version”Include schemaVersion: '1.0' in your plugin. This allows future schema versions without breaking existing plugins.
export default { name: '@agent-detective/my-adapter', version: '1.0.0', schemaVersion: '1.0', // required - must be '1.0' // ...};4. TaskEvent Interface
Section titled “4. TaskEvent Interface”All plugins produce a normalized TaskEvent object that the core processes identically regardless of source.
const taskEvent: TaskEvent = { id: 'PROJ-123', // Unique task ID (e.g., Jira issue key) type: 'incident', // 'incident' | 'question' | 'command' source: '@agent-detective/my-adapter', // Plugin name
message: 'User reported login failure...', // Original text to process
context: { repoPath: '/path/to/project', // Repository path (null = no repo access) threadId: null, // Session ID (null = new session) cwd: process.cwd(), // Working directory },
replyTo: { type: 'issue', // 'issue' | 'channel' | 'user' id: 'PROJ-123', // Target identifier },
metadata: { // Source-specific data labels: ['backend', 'auth'], issueType: 'Bug', reporter: 'john@example.com', }};Event Types
Section titled “Event Types”| Type | Description | Typical Reply |
|---|---|---|
incident | Something broke, needs investigation | Root cause analysis comment |
question | User asking a question | Conversational response |
command | Bot command (e.g., /analyze) | Command output |
Reply Targets
Section titled “Reply Targets”| Type | Description |
|---|---|
issue | Jira issue, GitHub PR, etc. |
channel | Chat channel (Telegram group, Slack channel) |
user | Direct message to a user |
5. Example: Jira-Style Webhook Plugin
Section titled “5. Example: Jira-Style Webhook Plugin”This is the most common plugin pattern. The plugin receives HTTP POST webhooks, normalizes them into TaskEvent, and posts analysis comments back to the source.
Project Structure
Section titled “Project Structure”packages/my-jira/├── src/│ ├── index.ts # Plugin entry point│ ├── normalizer.ts # Payload → TaskEvent│ └── jira-client.ts # Jira API client├── test/│ └── normalizer.test.ts├── package.json└── tsconfig.jsonpackage.json
Section titled “package.json”{ "name": "@myorg/agent-detective-jira", "version": "1.0.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsc -p tsconfig.build.json", "test": "tsx --test" }, "dependencies": { "@agent-detective/sdk": "^1.0.0" }, "devDependencies": { "typescript": "^5.7.0", "tsx": "^4.19.0" }}Webhook + services pattern (sketch)
Section titled “Webhook + services pattern (sketch)”Use context.getService to obtain the local-repos service (or the REPO_MATCHER_SERVICE matcher’s matchByLabels when you only need a path). Use context.enqueue for serialized work. Plugin routes are registered via defineRoute() + registerRoutes(scope, ...) from @agent-detective/sdk (see plugin-development.md); the effective URL is under /plugins/{sanitized-name}/.
The official jira-adapter implements the full Jira + fan-out flow — treat it as the reference, not the stale snippet below. This sketch shows the correct PluginContext surface:
// Sketch — not a full drop-in. See packages/jira-adapter for production code.import { REPO_MATCHER_SERVICE, type Plugin, type PluginContext, type TaskEvent, type RepoMatcher, type LocalReposService,} from '@agent-detective/sdk';
type JiraClient = { addComment(issueKey: string, text: string): Promise<void> };
const plugin: Plugin = { name: '@myorg/my-jira', version: '0.1.0', schemaVersion: '1.0', dependsOn: ['@agent-detective/local-repos-plugin'], schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true } }, required: [] },
register(_app, context: PluginContext) { const { config, agentRunner, enqueue, logger } = context; if (!config.enabled) return;
const matcher = context.getService<RepoMatcher>(REPO_MATCHER_SERVICE); let local: LocalReposService; try { local = context.getService<LocalReposService>('@agent-detective/local-repos-plugin'); } catch { logger.error('local-repos-plugin is required for this example'); return; } const jira = makeJiraClient();
_app.post('/webhook/jira', async (req, res) => { const task = normalizePayload(req.body) as TaskEvent; const labels = (task.metadata?.labels as string[] | undefined) ?? []; const m = matcher.matchByLabels(labels); task.context = { ...task.context, repoPath: m?.path ?? null };
void enqueue(task.id, async () => { let ctxText = ''; if (task.context.repoPath) { const built = await local.buildRepoContext(task.context.repoPath, { maxCommits: 50 }); ctxText = local.formatRepoContextForPrompt(built); } await agentRunner.runAgentForChat(task.id, buildPrompt(task, ctxText), { onFinal: async (t) => { await jira.addComment(task.replyTo.id, t); }, }); }); res.json({ status: 'queued' }); }); },};
function buildPrompt(task: TaskEvent, repo: string) { return [task.message, repo ? `### Repository context\n${repo}` : ''].filter(Boolean).join('\n\n');}function normalizePayload(body: unknown): TaskEvent { void body; throw new Error('See your normalizer / packages/jira-adapter');}function makeJiraClient(): JiraClient { return { async addComment() {} };}
export default plugin;Minimal Normalizer
Section titled “Minimal Normalizer”import type { TaskEvent } from '@agent-detective/sdk';
export function normalizePayload(payload: JiraPayload): TaskEvent { const issue = payload.issue || payload;
return { id: issue.key || String(Date.now()), type: 'incident', source: '@myorg/agent-detective-jira', message: buildIncidentMessage(issue), context: { repoPath: null, threadId: null, cwd: process.cwd(), }, replyTo: { type: 'issue', id: issue.key, }, metadata: { labels: issue.fields?.labels || [], projectKey: issue.fields?.project?.key || '', issueType: issue.fields?.issuetype?.name || 'Task', reporter: issue.fields?.reporter?.displayName || 'unknown', }, };}
function buildIncidentMessage(issue: JiraIssue): string { const desc = issue.fields?.description || ''; return `## Incident: ${issue.fields?.summary || 'No title'}\n\n### Description\n${desc}`;}6. Example: Interactive Question Plugin (Telegram-Style)
Section titled “6. Example: Interactive Question Plugin (Telegram-Style)”This pattern handles conversational messages where a user asks a question. The plugin maintains conversation threads via threadId, and supports streaming progress updates.
Key Differences from Webhook Plugins
Section titled “Key Differences from Webhook Plugins”type: 'question'instead ofincidentreplyTo.type: 'user'or'channel'- Uses
threadIdto continue conversations - Supports streaming progress via
onProgresscallback - Agent can respond without repo context (optional analysis)
Full Implementation
Section titled “Full Implementation”import type { Plugin, PluginContext, TaskEvent } from '@agent-detective/sdk';
const plugin: Plugin = { name: '@myorg/agent-detective-telegram', version: '0.1.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, botToken: { type: 'string', default: '' }, allowedChannels: { type: 'array', default: [] }, defaultRepoPath: { type: 'string', default: null }, }, required: ['botToken'], },
register(app, context: PluginContext) { const { config, agentRunner, enqueue, logger } = context;
if (!config.enabled) return;
// Optional: resolve a default repo from local-repos (or another service you register). // There is no context.repoMapping — use getService<RepoMatcher>(REPO_MATCHER_SERVICE) or // getService<LocalReposService>('@agent-detective/local-repos-plugin') and your own rules.
const telegram = createTelegramBot(config.botToken as string);
telegram.on('message', async (msg) => { const { chatId, text, messageId } = msg;
const defaultPath = (config.defaultRepoPath as string) || null; // There is no context.repoMapping. Resolve paths via your own config, // or getService<RepoMatcher>(REPO_MATCHER_SERVICE) / LocalReposService. const repoPath = defaultPath;
const taskEvent: TaskEvent = { id: `${chatId}:${messageId}`, type: 'question', source: '@myorg/agent-detective-telegram', message: text.replace(/proj:\S+\s*/, '').trim(), context: { repoPath, threadId: String(chatId), cwd: process.cwd(), }, replyTo: { type: 'user', id: String(chatId), }, metadata: { messageId, chatId, username: msg.from?.username, }, };
void enqueue(taskEvent.context.threadId ?? 'default', async () => { const out = await agentRunner.runAgentForChat(taskEvent.id, taskEvent.message, { contextKey: taskEvent.context.threadId ?? taskEvent.id, repoPath: taskEvent.context.repoPath, }); logger.info('Reply', { out: out.slice(0, 200) }); // await telegram.sendMessage(chatId, out); }); });
logger.info('Telegram adapter registered'); },};
export default plugin;7. Example: Slash Command Plugin
Section titled “7. Example: Slash Command Plugin”Handles bot commands like /analyze, /status, /help. Commands are typically prefixed and parsed from regular messages.
import type { Plugin, PluginContext, TaskEvent } from '@agent-detective/sdk';
const plugin: Plugin = { name: '@myorg/agent-detective-slack', version: '0.1.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, webhookPath: { type: 'string', default: '/webhook/slack' }, signingSecret: { type: 'string', default: '' }, }, required: ['webhookPath'], },
register(app, context: PluginContext) { const { config, agentRunner, logger } = context;
if (!config.enabled) return;
app.post(config.webhookPath as string, async (req, res) => { res.json({ status: 'ok' });
const { command, text, user_id, channel_id } = req.body;
if (command === '/analyze') { await handleAnalyze({ command, text, user_id, channel_id }, { config, agentRunner, logger }); } else if (command === '/status') { await handleStatus({ command, user_id, channel_id }, { config, agentRunner, logger }); } else { logger.warn(`Unknown command: ${command}`); } });
logger.info('Slash command adapter registered'); },};
export default plugin;8. Example: Polling Plugin
Section titled “8. Example: Polling Plugin”Polls an external API periodically instead of receiving webhooks. Useful for checking status, monitoring, or periodic reporting.
import type { Plugin, PluginContext } from '@agent-detective/sdk';
const plugin: Plugin = { name: '@myorg/agent-detective-poller', version: '0.1.0', schemaVersion: '1.0',
schema: { type: 'object', properties: { enabled: { type: 'boolean', default: true }, pollIntervalMs: { type: 'number', default: 60000 }, apiEndpoint: { type: 'string', default: '' }, apiKey: { type: 'string', default: '' }, }, required: ['apiEndpoint'], },
register(app, context: PluginContext) { const { config, agentRunner, logger } = context;
if (!config.enabled) return;
let intervalId: ReturnType<typeof setInterval> | null = null;
app.post('/control/poller/start', (req, res) => { startPolling(); res.json({ status: 'started' }); });
app.post('/control/poller/stop', (req, res) => { stopPolling(); res.json({ status: 'stopped' }); });
async function poll() { try { const events = await fetchExternalEvents(config); for (const rawEvent of events) { const taskEvent = normalizeEvent(rawEvent); await processTask(taskEvent, { config, agentRunner, logger }); } } catch (err) { logger.error(`Polling error: ${(err as Error).message}`); } }
function startPolling() { if (intervalId) return; logger.info(`Starting poller with interval ${config.pollIntervalMs}ms`); poll(); intervalId = setInterval(poll, config.pollIntervalMs as number); }
function stopPolling() { if (intervalId) { clearInterval(intervalId); intervalId = null; logger.info('Poller stopped'); } }
startPolling(); process.on('SIGTERM', stopPolling); },};
export default plugin;9. Publishing a Plugin as an npm Package
Section titled “9. Publishing a Plugin as an npm Package”Option A: Local packages/ Directory
Section titled “Option A: Local packages/ Directory”For development within the monorepo, put your plugin in packages/:
agent-detective/├── packages/│ ├── jira-adapter/ # Official│ └── my-adapter/ # Your plugin│ ├── package.json│ ├── src/│ │ └── index.ts│ └── dist/ # Built outputOption B: Separate npm Package
Section titled “Option B: Separate npm Package”To publish your plugin as a standalone npm package:
1. Create the plugin package structure:
my-adapter/├── src/│ ├── index.ts # Plugin entry point│ └── normalizer.ts├── test/│ └── normalizer.test.ts├── package.json└── tsconfig.json2. package.json:
{ "name": "@myorg/agent-detective-my-adapter", "version": "1.0.0", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "scripts": { "build": "tsc", "test": "tsx --test" }, "dependencies": { "@agent-detective/sdk": "^1.0.0" }, "devDependencies": { "typescript": "^5.7.0", "tsx": "^4.19.0" }, "keywords": ["agent-detective", "plugin"]}3. Publish to npm:
cd my-adapterpnpm installpnpm run buildpnpm publish --access public4. Install in agent-detective:
cd agent-detectivepnpm add @myorg/agent-detective-my-adapter5. Configure in config/default.json:
{ "plugins": [ { "package": "@myorg/agent-detective-my-adapter", "options": { "enabled": true, "webhookPath": "/webhook/my" } } ]}Loading Priority
Section titled “Loading Priority”The plugin system tries to load plugins in this order:
- If
packagestarts with./,../, or/→ treat as file path relative to project root - Try
import(packageName)from node_modules - Try
import(packages/{name}/src/index.js)where@agent-detective/Xmaps topackages/X/src/index.js
10. Core API Reference
Section titled “10. Core API Reference”AgentRunner
Section titled “AgentRunner”Created by createAgentRunner() with shell execution utilities. Available in plugins via context.agentRunner.
agentRunner.runAgentForChat(taskId, prompt, options)
Section titled “agentRunner.runAgentForChat(taskId, prompt, options)”Runs an AI agent with a prompt.
Parameters:
| Parameter | Type | Description |
|---|---|---|
taskId | string | Unique task identifier (used for queuing) |
prompt | string | Full prompt to send to the agent |
options.contextKey | string | Context key for conversation continuity (default: taskId) |
options.repoPath | string|null | Repository path for context |
options.cwd | string | Working directory (default: process.cwd()) |
options.agentId | string | Override agent (default: from config) |
options.onProgress | function | Called with progress updates (streaming agents) |
options.onFinal | function | Called with final response text |
Returns: Promise<string> - the agent’s response text
Example:
await agentRunner.runAgentForChat(taskEvent.id, fullPrompt, { contextKey: taskEvent.context.threadId, repoPath: taskEvent.context.repoPath, onProgress: (messages) => { for (const msg of messages) { sendMessage(userId, `Thinking: ${msg}`); } }, onFinal: async (responseText) => { await jiraClient.addComment(issueId, responseText); },});agentRunner.stopActiveRun(taskId, contextKey)
Section titled “agentRunner.stopActiveRun(taskId, contextKey)”Stops an active agent run.
Returns: Promise<{ status: 'idle' | 'stopping' }>
RepoMatcher (service) and local-repos service
Section titled “RepoMatcher (service) and local-repos service”PluginContext does not include repoMapping or buildRepoContext directly.
REPO_MATCHER_SERVICE— register/consume aRepoMatcher(matchByLabels,matchAllByLabels,listConfiguredLabels). The bundled local-repos-plugin provides the implementation; the jira-adapter consumes it for label → repo resolution.@agent-detective/local-repos-pluginservice — aLocalReposServicewithlocalRepos,buildRepoContext(repoPath, options?), andformatRepoContextForPrompt.BuildRepoContextOptions(re-exported from@agent-detective/sdk) only supports{ maxCommits?: number }(file search was removed; agents search the tree themselves).
import { REPO_MATCHER_SERVICE, type RepoMatcher, type LocalReposService,} from '@agent-detective/sdk';
const matcher = context.getService<RepoMatcher>(REPO_MATCHER_SERVICE);const m = matcher.matchByLabels(['my-repo-name']);
const local = context.getService<LocalReposService>('@agent-detective/local-repos-plugin');if (m) { const built = await local.buildRepoContext(m.path, { maxCommits: 50 }); const text = local.formatRepoContextForPrompt(built);}11. Error Handling
Section titled “11. Error Handling”Plugin Loading Errors
Section titled “Plugin Loading Errors”The plugin system catches errors during loading and logs them as warnings. The server continues running.
try { plugin.register(app, pluginContext);} catch (err) { logger.warn(`Failed to load plugin ${pluginName}: ${(err as Error).message}. Continuing...`); return null;}Best Practice: Use config.enabled to cleanly disable a plugin without errors:
register(app, context: PluginContext) { if (!context.config.enabled) { context.logger.info('Plugin is disabled'); return; } // normal setup}Per-Task Error Isolation
Section titled “Per-Task Error Isolation”Wrap each task in try-catch to prevent one failing task from affecting others:
enqueueTask(taskId, async () => { try { await processTask(taskEvent, context); } catch (err) { logger.error(`Task ${taskId} failed: ${(err as Error).message}`); }});12. Testing Patterns
Section titled “12. Testing Patterns”createPluginSystem in unit tests
Section titled “createPluginSystem in unit tests”createPluginSystem requires events (an EventBus). Pass a no-op bus and a minimal AgentRunner (registerAgent, listAgents, runAgentForChat, stopActiveRun). Omit taskQueue to use the default in-memory queue, or pass a stub { enqueue } to assert queue behavior. Use the enqueue property on the return value to run work the same way the production app does.
Mock Clients
Section titled “Mock Clients”Create an in-memory mock client that stores data instead of making external API calls:
export interface MockJiraClient { comments: Map<string, MockComment[]>; addComment(issueKey: string, commentText: string): Promise<{ success: boolean; issueKey: string }>;}
export function createMockJiraClient(): MockJiraClient { const comments = new Map<string, MockComment[]>();
return { comments, async addComment(issueKey: string, commentText: string) { if (!comments.has(issueKey)) { comments.set(issueKey, []); } comments.get(issueKey)!.push({ text: commentText, createdAt: new Date().toISOString(), }); console.info(`[MOCK] Added comment to ${issueKey}`); return { success: true, issueKey }; }, };}Testing with Fixtures
Section titled “Testing with Fixtures”Store sample payloads as JSON fixtures:
{ "issue": { "key": "PROJ-123", "fields": { "summary": "Login fails for users", "description": "Users cannot login after latest deployment", "labels": ["backend", "auth"], "project": { "key": "PROJ" }, "issuetype": { "name": "Bug" } } }}Plugin Unit Test Example
Section titled “Plugin Unit Test Example”import { strict as assert } from 'assert';import { normalizePayload } from '../src/normalizer.js';import issueCreated from './fixtures/issue-created.json';
test('normalizePayload extracts correct fields', () => { const taskEvent = normalizePayload(issueCreated);
assert.equal(taskEvent.id, 'PROJ-123'); assert.equal(taskEvent.type, 'incident'); assert.equal(taskEvent.source, '@myorg/agent-detective-jira'); assert.ok(taskEvent.message.includes('Login fails')); assert.deepEqual(taskEvent.metadata.labels, ['backend', 'auth']); assert.equal(taskEvent.replyTo.type, 'issue'); assert.equal(taskEvent.replyTo.id, 'PROJ-123');});
test('mock client stores comments', () => { const mockClient = createMockJiraClient();
mockClient.addComment('PROJ-123', 'Root cause analysis...');
const comments = mockClient.comments.get('PROJ-123'); assert.equal(comments?.length, 1); assert.ok(comments?.[0].text.includes('Root cause'));});13. Third-Party Plugins
Section titled “13. Third-Party Plugins”Installing and wiring custom plugins (npm, private registry, path or bind-mounted directory) is covered in one place: extending-with-plugins.md. The rest of this document describes APIs, patterns, and the official bundles. For a step-by-step TypeScript template and long examples, see plugin-development.md.
14. Official Bundled Plugins
Section titled “14. Official Bundled Plugins”The default distribution includes these first-party plugins (enable/disable via config.plugins):
local-repos-plugin
Section titled “local-repos-plugin”Manages local repository configuration with validation, tech stack detection, and summary generation.
Package: @agent-detective/local-repos-plugin
Can Disable: Yes ("enabled": false in config)
Configuration:
{ "plugins": [ { "package": "@agent-detective/local-repos-plugin", "options": { "enabled": true, "repos": [ { "name": "backend", "path": "/repos/backend" } ] } } ]}jira-adapter
Section titled “jira-adapter”Handles Jira webhooks and dispatches deterministic, label-based analysis via
the RepoMatcher service exposed by local-repos-plugin.
Package: @agent-detective/jira-adapter
Can Disable: Yes ("enabled": false in config)
Operator guide: jira-adapter.md — webhooks vs OAuth vs Basic, trigger phrases, OAuth install (start/callback), token copy + rotation, and identity notes.
Webhook URL (fixed): POST …/plugins/agent-detective-jira-adapter/webhook/jira.
OAuth routes (optional): GET …/oauth/start and GET …/oauth/callback when oauthClientId, oauthClientSecret, and oauthRedirectBaseUrl are set.
Webhook Behavior Configuration
Section titled “Webhook Behavior Configuration”The webhookBehavior option lets you define what action to take for each Jira webhook event type:
{ "plugins": [ { "package": "@agent-detective/jira-adapter", "options": { "enabled": true, "mockMode": false, "baseUrl": "https://your-domain.atlassian.net", "email": "bot@example.com", "apiToken": "your-api-token", "webhookBehavior": { "defaults": { "action": "ignore", "acknowledgmentMessage": "Thanks for the update! I will review this issue shortly." }, "events": { "jira:issue_created": { "action": "analyze" }, "jira:comment_created": { "action": "analyze" } } } } } ]}Actions
Section titled “Actions”| Action | Description |
|---|---|
analyze | Match the issue’s labels against configured repos; on matches, fan out one analysis per repo. On issue_created without a match, post a “please add a matching label and comment <trigger>” reminder. On jira:comment_created, run the match only when the comment body contains retryTriggerPhrase and wasn’t authored by the adapter itself. No automatic retry on issue_updated. |
acknowledge | Post a fixed acknowledgment comment (no matching, no analysis) |
ignore | Log the event and skip processing |
Configuration Options
Section titled “Configuration Options”| Option | Description |
|---|---|
webhookBehavior.defaults.action | Default action for unhandled events |
webhookBehavior.defaults.acknowledgmentMessage | Default message for acknowledge action |
webhookBehavior.events.{eventType}.action | Action for a specific event type |
webhookBehavior.events.{eventType}.acknowledgmentMessage | Override message for a specific event |
webhookBehavior.events.{eventType}.analysisPrompt | Custom analysis prompt template |
analysisReadOnly | When true (default), analyze tasks run with write/edit/shell tools denied |
missingLabelsMessage | Markdown template posted when no label matches on issue_created or on a comment-triggered retry. Supports {available_labels}, {issue_key}, and {trigger_phrase} placeholders. |
maxReposPerIssue | Safety cap on fan-out when an issue’s labels match multiple repos. Default 5; 0 disables the cap. Extra matches are logged and noted in the acknowledgment. |
retryTriggerPhrase | Case-insensitive substring that, when found in a jira:comment_created body authored by a non-adapter user, kicks off a fresh label match. Default #agent-detective analyze. Pick something unlikely to appear in normal conversation — any matching comment runs analysis. |
jiraUser.accountId / jiraUser.email | Optional identity of the Jira account the adapter posts as. Used together with the visible “Posted by agent-detective” footer marker to filter out adapter-authored comments so the retry flow can’t loop. Comments from this account are ignored even if the marker is stripped. |
fetchIssueComments | When true, the adapter fetches all comments on the Jira ticket at PR-trigger time and passes human-authored ones (app comments excluded) to pr-pipeline as additional agent context. Default false. |
Supported Event Types
Section titled “Supported Event Types”| Event Type | Default Action |
|---|---|
jira:issue_created | analyze |
jira:comment_created | analyze (gated: only runs when the comment contains retryTriggerPhrase and is not adapter-authored) |
jira:issue_updated | ignore (falls to default) — no more changelog-based auto-retry |
jira:issue_deleted | ignore (falls to default) |
Repository matching
Section titled “Repository matching”Matching is label-only and deterministic. The Jira adapter consumes
the RepoMatcher service (REPO_MATCHER_SERVICE from
@agent-detective/sdk) which local-repos-plugin registers. The matcher
exposes two methods:
matchByLabels(labels) → MatchedRepo | null— first match (label-order).matchAllByLabels(labels) → MatchedRepo[]— every match, returned in configured-repo order for stable fan-out.
On a match, the adapter emits one TASK_CREATED per matched repo with
context.repoPath, context.cwd, and metadata.matchedRepo pre-set so the
downstream analyzer has no selection work to do. Task ids are composite —
<ISSUE-KEY>:<repo-name> — so parallel fan-out runs don’t collapse in the
orchestrator queue. Result comments carry a ## Analysis for \
Retries are user-initiated via jira:comment_created: if a ticket was
created without a matching label, the adapter posts a reminder listing
every configured label plus the exact retryTriggerPhrase to include in a
follow-up comment. Posting a comment that contains the phrase re-runs the
match against the ticket’s current labels — no changelog parsing, no
delta bookkeeping. Adapter-authored comments carry a visible
“Posted by agent-detective · ad-v1” footer (rendered as a plain
Markdown --- + italic line so it round-trips reliably through Jira’s
Markdown → ADF pipeline); the comment_created handler drops anything
containing that footer, optionally cross-checked against
jiraUser.accountId / jiraUser.email. Two last-resort circuit
breakers backstop the above: the adapter refuses to post more than one
missing-labels reminder to the same issue within a 60-second window,
and it refuses to auto-analyze the same (issue, repo) pair more than
once per 10 minutes on non-comment-triggered paths (explicit comment
retries bypass the cooldown because a human explicitly asked for the
re-run). The payload-shape event classifier also treats any
changelog.items / changelog.histories / changelog.total > 0
signal as issue_updated (default ignore) rather than
issue_created, so result comments that Jira Automation echoes back as
bare-issue payloads never get mis-routed into analyze. Together these
layers guarantee result comments and reminders can never loop back into
the retry handler. There is no agent-driven discovery fallback. See the “Matching
a ticket to a repository” section in
e2e/jira-manual-e2e.md for the full flow.
Analysis Configuration
Section titled “Analysis Configuration”{ "analysis": { "maxCommits": 50 }}linear-adapter
Section titled “linear-adapter”Handles Linear webhooks (Issues, Comments) and mirrors the Jira adapter’s
label-based analyze / PR fan-out using RepoMatcher from
local-repos-plugin. Supports signed webhooks, OAuth (install flow +
refresh token grant), and a personal API key for local testing.
Package: @agent-detective/linear-adapter
Can disable: Yes ("enabled": false).
Operator guide: linear-adapter.md — webhooks vs OAuth vs
trigger phrases, OAuth install (Linear app form, callback URLs, manual token
copy, apiKey vs refresh-only), comment attribution (actor=user vs
actor=app), FAQ (404, @mentions), dedup header, env tables, and links to the
generated options reference and manual E2E.
Webhook URL (fixed): POST …/plugins/agent-detective-linear-adapter/webhook/linear.
OAuth routes (optional): GET …/oauth/start and GET …/oauth/callback when
oauthClientId, oauthClientSecret, and oauthRedirectBaseUrl are set.
Canonical webhookBehavior.events keys include linear:Issue:create and
linear:Comment:create (defaults match Jira-style analyze). See
generated/plugin-options.md for
every option.