BMAD-METHOD/.claude/tools/sdk/tool-runner.mjs

514 lines
13 KiB
JavaScript

#!/usr/bin/env node
/**
* Tool Runner Pattern with Type-Safe Zod Schemas
*
* Implements Claude SDK best practices for custom tool definitions:
* - Type-safe tool invocation with Zod schema validation
* - Automatic parameter validation and error messages
* - Reusable tool definitions for BMAD operations
* - Integration with workflow executor
*
* Based on: https://docs.claude.com/en/docs/agent-sdk/tool-use.md
*
* @version 2.0.0
* @date 2025-11-13
*/
import { z } from 'zod';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
// ============================================================================
// Base Tool Runner Class
// ============================================================================
/**
* Base class for type-safe tool execution
*/
class ToolRunner {
constructor(name, description, inputSchema) {
this.name = name;
this.description = description;
this.inputSchema = inputSchema;
}
/**
* Validate and execute tool
*/
async execute(params) {
try {
// Validate parameters using Zod schema
const validatedParams = await this.inputSchema.parseAsync(params);
// Execute tool implementation
const result = await this.run(validatedParams);
return {
success: true,
tool: this.name,
result
};
} catch (error) {
if (error instanceof z.ZodError) {
// Type validation error
return {
success: false,
tool: this.name,
error: 'Validation failed',
details: error.errors.map(e => ({
path: e.path.join('.'),
message: e.message,
code: e.code
}))
};
}
// Runtime error
return {
success: false,
tool: this.name,
error: error.message,
stack: error.stack
};
}
}
/**
* Tool implementation - to be overridden by subclasses
*/
async run(params) {
throw new Error('Tool.run() must be implemented by subclass');
}
/**
* Get tool definition for Claude SDK
*/
getDefinition() {
return {
name: this.name,
description: this.description,
input_schema: this.zodToJsonSchema(this.inputSchema)
};
}
/**
* Convert Zod schema to JSON Schema for Claude
*/
zodToJsonSchema(zodSchema) {
// Simplified conversion - in production, use @anatine/zod-to-json-schema
// For now, we'll use a basic manual conversion
return {
type: 'object',
properties: {},
required: []
};
}
}
// ============================================================================
// BMAD Custom Tools
// ============================================================================
/**
* Validation Tool - Validates JSON against schema
*/
class ValidationTool extends ToolRunner {
constructor() {
super(
'bmad_validate',
'Validate JSON artifact against JSON Schema with auto-fix capability',
z.object({
schema_path: z.string().describe('Path to JSON Schema file'),
artifact_path: z.string().describe('Path to JSON artifact to validate'),
autofix: z.boolean().optional().default(false).describe('Attempt automatic fixes for common issues'),
gate_path: z.string().optional().describe('Path to save validation gate record')
})
);
}
async run(params) {
const { schema_path, artifact_path, autofix, gate_path } = params;
// Build validation command
const cmd = [
'node',
path.join(PROJECT_ROOT, '.claude/tools/gates/gate.mjs'),
'--schema', schema_path,
'--input', artifact_path
];
if (autofix) {
cmd.push('--autofix', '1');
}
if (gate_path) {
cmd.push('--gate', gate_path);
}
try {
const { stdout, stderr } = await execAsync(cmd.join(' '));
return {
validated: true,
schema: schema_path,
artifact: artifact_path,
output: stdout,
warnings: stderr || null
};
} catch (error) {
return {
validated: false,
schema: schema_path,
artifact: artifact_path,
error: error.message,
output: error.stdout,
stderr: error.stderr
};
}
}
}
/**
* Rendering Tool - Renders JSON to Markdown
*/
class RenderingTool extends ToolRunner {
constructor() {
super(
'bmad_render',
'Render JSON artifact to human-readable Markdown using BMAD templates',
z.object({
template_type: z.enum([
'project-brief',
'prd',
'architecture',
'ux-spec',
'test-plan'
]).describe('Type of artifact to render'),
artifact_path: z.string().describe('Path to JSON artifact'),
output_path: z.string().optional().describe('Path to save rendered Markdown')
})
);
}
async run(params) {
const { template_type, artifact_path, output_path } = params;
// Build rendering command
const cmd = [
'node',
path.join(PROJECT_ROOT, '.claude/tools/renderers/bmad-render.mjs'),
template_type,
artifact_path
];
try {
const { stdout, stderr } = await execAsync(cmd.join(' '));
// Save to file if output path provided
if (output_path) {
await fs.writeFile(output_path, stdout, 'utf-8');
}
return {
rendered: true,
template: template_type,
artifact: artifact_path,
output_path: output_path || null,
markdown: stdout,
warnings: stderr || null
};
} catch (error) {
return {
rendered: false,
template: template_type,
artifact: artifact_path,
error: error.message,
stderr: error.stderr
};
}
}
}
/**
* Quality Gate Tool - Check quality metrics and enforce thresholds
*/
class QualityGateTool extends ToolRunner {
constructor() {
super(
'bmad_quality_gate',
'Evaluate quality metrics and enforce quality thresholds',
z.object({
metrics: z.object({
completeness: z.number().min(0).max(10).optional(),
clarity: z.number().min(0).max(10).optional(),
technical_feasibility: z.number().min(0).max(10).optional(),
alignment: z.number().min(0).max(10).optional()
}).describe('Quality metrics to evaluate'),
threshold: z.number().min(0).max(10).default(7.0).describe('Minimum acceptable quality score'),
agent: z.string().describe('Agent that produced the artifact'),
step: z.number().describe('Workflow step number')
})
);
}
async run(params) {
const { metrics, threshold, agent, step } = params;
// Calculate overall quality score (weighted average)
const scores = Object.values(metrics).filter(v => typeof v === 'number');
const overallScore = scores.reduce((sum, score) => sum + score, 0) / scores.length;
const passed = overallScore >= threshold;
// Generate recommendations if quality is low
const recommendations = [];
if (!passed) {
for (const [metric, score] of Object.entries(metrics)) {
if (score < threshold) {
recommendations.push({
metric,
current_score: score,
target_score: threshold,
gap: threshold - score,
suggestion: this.getImprovementSuggestion(metric, score)
});
}
}
}
return {
passed,
overall_score: overallScore,
threshold,
agent,
step,
metrics,
recommendations,
timestamp: new Date().toISOString()
};
}
getImprovementSuggestion(metric, score) {
const suggestions = {
completeness: 'Add missing sections and ensure all required fields are populated',
clarity: 'Improve documentation clarity with specific examples and concrete details',
technical_feasibility: 'Review technical decisions and ensure they are implementable',
alignment: 'Verify consistency with previous agent outputs and business requirements'
};
return suggestions[metric] || 'Review and improve this metric';
}
}
/**
* Context Update Tool - Update workflow context bus
*/
class ContextUpdateTool extends ToolRunner {
constructor() {
super(
'bmad_context_update',
'Update workflow context with agent outputs and metadata',
z.object({
agent: z.string().describe('Agent name'),
step: z.number().describe('Step number'),
artifact_path: z.string().describe('Path to artifact JSON'),
quality_score: z.number().min(0).max(10).optional().describe('Quality score'),
metadata: z.record(z.any()).optional().describe('Additional metadata')
})
);
}
async run(params) {
const { agent, step, artifact_path, quality_score, metadata } = params;
// Build context update command
const cmd = [
'node',
path.join(PROJECT_ROOT, '.claude/tools/context/update-session.mjs'),
'--agent', agent,
'--step', step.toString(),
'--artifact', artifact_path
];
if (quality_score !== undefined) {
cmd.push('--quality', quality_score.toString());
}
if (metadata) {
cmd.push('--metadata', JSON.stringify(metadata));
}
try {
const { stdout, stderr } = await execAsync(cmd.join(' '));
return {
updated: true,
agent,
step,
artifact: artifact_path,
output: stdout,
warnings: stderr || null
};
} catch (error) {
return {
updated: false,
agent,
step,
error: error.message,
stderr: error.stderr
};
}
}
}
/**
* Cost Tracking Tool - Track and report costs
*/
class CostTrackingTool extends ToolRunner {
constructor() {
super(
'bmad_cost_track',
'Track API costs by agent and generate cost reports',
z.object({
message_id: z.string().describe('Message ID for deduplication'),
agent: z.string().describe('Agent name'),
model: z.string().describe('Model used'),
usage: z.object({
input_tokens: z.number(),
output_tokens: z.number(),
cache_creation_tokens: z.number().optional(),
cache_read_tokens: z.number().optional()
}).describe('Token usage data')
})
);
}
async run(params) {
const { message_id, agent, model, usage } = params;
// This would integrate with the CostTracker class
// For now, we'll return a simulated response
// Calculate cost (simplified)
const pricing = {
'claude-sonnet-4-5': { input: 0.00003, output: 0.00015 },
'claude-haiku-4': { input: 0.000001, output: 0.000005 },
'claude-opus-4-1': { input: 0.00015, output: 0.00075 }
};
const modelPricing = pricing[model] || pricing['claude-sonnet-4-5'];
const cost = (usage.input_tokens * modelPricing.input) +
(usage.output_tokens * modelPricing.output);
return {
tracked: true,
message_id,
agent,
model,
usage,
cost_usd: cost,
timestamp: new Date().toISOString()
};
}
}
// ============================================================================
// Tool Registry
// ============================================================================
/**
* Registry of all BMAD tools
*/
class ToolRegistry {
constructor() {
this.tools = new Map();
this.registerDefaultTools();
}
/**
* Register default BMAD tools
*/
registerDefaultTools() {
this.register(new ValidationTool());
this.register(new RenderingTool());
this.register(new QualityGateTool());
this.register(new ContextUpdateTool());
this.register(new CostTrackingTool());
}
/**
* Register a tool
*/
register(tool) {
if (!(tool instanceof ToolRunner)) {
throw new Error('Tool must be an instance of ToolRunner');
}
this.tools.set(tool.name, tool);
}
/**
* Get a tool by name
*/
get(name) {
const tool = this.tools.get(name);
if (!tool) {
throw new Error(`Unknown tool: ${name}`);
}
return tool;
}
/**
* Execute a tool
*/
async execute(name, params) {
const tool = this.get(name);
return await tool.execute(params);
}
/**
* Get all tool definitions for Claude SDK
*/
getDefinitions() {
return Array.from(this.tools.values()).map(tool => tool.getDefinition());
}
/**
* List all available tools
*/
list() {
return Array.from(this.tools.keys());
}
}
// ============================================================================
// Export
// ============================================================================
// Create global registry instance
const globalRegistry = new ToolRegistry();
export {
ToolRunner,
ValidationTool,
RenderingTool,
QualityGateTool,
ContextUpdateTool,
CostTrackingTool,
ToolRegistry,
globalRegistry
};