claude-task-master/scripts/modules/prompt-manager.js
Joe Danziger a65ad0a47c
feat: Centralize AI prompts into JSON templates (#882)
* centralize prompt management

* add changeset

* add variant key to determine prompt version

* update tests and add prompt manager test

* determine internal path, don't use projectRoot

* add promptManager mock

* detailed prompt docs

* add schemas and validator packages

* add validate prompts command

* add schema validation

* update tests

* move schemas to src/prompts/schemas

* use this.promptsDir for better semantics

* add prompt schemas

* version schema files & update links

* remove validate command

* expect dependencies

* update docs

* fix test

* remove suggestmode to ensure clean keys

* remove default variant from research and update schema

* now handled by prompt manager

* add manual test to verify prompts

* remove incorrect batch variant

* consolidate variants

* consolidate analyze-complexity to just default variant

* consolidate parse-prd variants

* add eq handler for handlebars

* consolidate research prompt variants

* use brevity

* consolidate variants for update subtask

* add not handler

* consolidate variants for update-task

* consolidate update-tasks variants

* add conditional content to prompt when research used

* update prompt tests

* show correct research variant

* make variant names link to below

* remove changset

* restore gitignore

* Merge branch 'next' of https://github.com/eyaltoledano/claude-task-master into joedanz/centralize-prompts

# Conflicts:
#	package-lock.json
#	scripts/modules/task-manager/expand-task.js
#	scripts/modules/task-manager/parse-prd.js

remove unused

* add else

* update tests

* update biome optional dependencies

* responsive html output for mobile
2025-07-10 09:52:11 +02:00

510 lines
14 KiB
JavaScript

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './utils.js';
import Ajv from 'ajv';
import addFormats from 'ajv-formats';
/**
* Manages prompt templates for AI interactions
*/
export class PromptManager {
constructor() {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
this.promptsDir = path.join(__dirname, '..', '..', 'src', 'prompts');
this.cache = new Map();
this.setupValidation();
}
/**
* Set up JSON schema validation
* @private
*/
setupValidation() {
this.ajv = new Ajv({ allErrors: true, strict: false });
addFormats(this.ajv);
try {
// Load schema from src/prompts/schemas
const schemaPath = path.join(
this.promptsDir,
'schemas',
'prompt-template.schema.json'
);
const schemaContent = fs.readFileSync(schemaPath, 'utf-8');
const schema = JSON.parse(schemaContent);
this.validatePrompt = this.ajv.compile(schema);
log('info', '✓ JSON schema validation enabled');
} catch (error) {
log('warn', `⚠ Schema validation disabled: ${error.message}`);
this.validatePrompt = () => true; // Fallback to no validation
}
}
/**
* Load a prompt template and render it with variables
* @param {string} promptId - The prompt template ID
* @param {Object} variables - Variables to inject into the template
* @param {string} [variantKey] - Optional specific variant to use
* @returns {{systemPrompt: string, userPrompt: string, metadata: Object}}
*/
loadPrompt(promptId, variables = {}, variantKey = null) {
try {
// Check cache first
const cacheKey = `${promptId}-${JSON.stringify(variables)}-${variantKey}`;
if (this.cache.has(cacheKey)) {
return this.cache.get(cacheKey);
}
// Load template
const template = this.loadTemplate(promptId);
// Validate parameters if schema validation is available
if (this.validatePrompt && this.validatePrompt !== true) {
this.validateParameters(template, variables);
}
// Select the variant - use specified key or select based on conditions
const variant = variantKey
? { ...template.prompts[variantKey], name: variantKey }
: this.selectVariant(template, variables);
// Render the prompts with variables
const rendered = {
systemPrompt: this.renderTemplate(variant.system, variables),
userPrompt: this.renderTemplate(variant.user, variables),
metadata: {
templateId: template.id,
version: template.version,
variant: variant.name || 'default',
parameters: variables
}
};
// Cache the result
this.cache.set(cacheKey, rendered);
return rendered;
} catch (error) {
log('error', `Failed to load prompt ${promptId}: ${error.message}`);
throw error;
}
}
/**
* Load a prompt template from disk
* @private
*/
loadTemplate(promptId) {
const templatePath = path.join(this.promptsDir, `${promptId}.json`);
try {
const content = fs.readFileSync(templatePath, 'utf-8');
const template = JSON.parse(content);
// Schema validation if available (do this first for detailed errors)
if (this.validatePrompt && this.validatePrompt !== true) {
const valid = this.validatePrompt(template);
if (!valid) {
const errors = this.validatePrompt.errors
.map((err) => `${err.instancePath || 'root'}: ${err.message}`)
.join(', ');
throw new Error(`Schema validation failed: ${errors}`);
}
} else {
// Fallback basic validation if no schema validation available
if (!template.id || !template.prompts || !template.prompts.default) {
throw new Error(
'Invalid template structure: missing required fields (id, prompts.default)'
);
}
}
return template;
} catch (error) {
if (error.code === 'ENOENT') {
throw new Error(`Prompt template '${promptId}' not found`);
}
throw error;
}
}
/**
* Validate parameters against template schema
* @private
*/
validateParameters(template, variables) {
if (!template.parameters) return;
const errors = [];
for (const [paramName, paramConfig] of Object.entries(
template.parameters
)) {
const value = variables[paramName];
// Check required parameters
if (paramConfig.required && value === undefined) {
errors.push(`Required parameter '${paramName}' missing`);
continue;
}
// Skip validation for undefined optional parameters
if (value === undefined) continue;
// Type validation
if (!this.validateParameterType(value, paramConfig.type)) {
errors.push(
`Parameter '${paramName}' expected ${paramConfig.type}, got ${typeof value}`
);
}
// Enum validation
if (paramConfig.enum && !paramConfig.enum.includes(value)) {
errors.push(
`Parameter '${paramName}' must be one of: ${paramConfig.enum.join(', ')}`
);
}
// Pattern validation for strings
if (paramConfig.pattern && typeof value === 'string') {
const regex = new RegExp(paramConfig.pattern);
if (!regex.test(value)) {
errors.push(
`Parameter '${paramName}' does not match required pattern: ${paramConfig.pattern}`
);
}
}
// Range validation for numbers
if (typeof value === 'number') {
if (paramConfig.minimum !== undefined && value < paramConfig.minimum) {
errors.push(
`Parameter '${paramName}' must be >= ${paramConfig.minimum}`
);
}
if (paramConfig.maximum !== undefined && value > paramConfig.maximum) {
errors.push(
`Parameter '${paramName}' must be <= ${paramConfig.maximum}`
);
}
}
}
if (errors.length > 0) {
throw new Error(`Parameter validation failed: ${errors.join('; ')}`);
}
}
/**
* Validate parameter type
* @private
*/
validateParameterType(value, expectedType) {
switch (expectedType) {
case 'string':
return typeof value === 'string';
case 'number':
return typeof value === 'number';
case 'boolean':
return typeof value === 'boolean';
case 'array':
return Array.isArray(value);
case 'object':
return (
typeof value === 'object' && value !== null && !Array.isArray(value)
);
default:
return true;
}
}
/**
* Select the best variant based on conditions
* @private
*/
selectVariant(template, variables) {
// Check each variant's condition
for (const [name, variant] of Object.entries(template.prompts)) {
if (name === 'default') continue;
if (
variant.condition &&
this.evaluateCondition(variant.condition, variables)
) {
return { ...variant, name };
}
}
// Fall back to default
return { ...template.prompts.default, name: 'default' };
}
/**
* Evaluate a condition string
* @private
*/
evaluateCondition(condition, variables) {
try {
// Create a safe evaluation context
const context = { ...variables };
// Simple condition evaluation (can be enhanced)
// For now, supports basic comparisons
const func = new Function(...Object.keys(context), `return ${condition}`);
return func(...Object.values(context));
} catch (error) {
log('warn', `Failed to evaluate condition: ${condition}`);
return false;
}
}
/**
* Render a template string with variables
* @private
*/
renderTemplate(template, variables) {
let rendered = template;
// Handle helper functions like (eq variable "value")
rendered = rendered.replace(
/\(eq\s+(\w+(?:\.\w+)*)\s+"([^"]+)"\)/g,
(match, path, compareValue) => {
const value = this.getNestedValue(variables, path);
return value === compareValue ? 'true' : 'false';
}
);
// Handle not helper function like (not variable)
rendered = rendered.replace(/\(not\s+(\w+(?:\.\w+)*)\)/g, (match, path) => {
const value = this.getNestedValue(variables, path);
return !value ? 'true' : 'false';
});
// Handle gt (greater than) helper function like (gt variable 0)
rendered = rendered.replace(
/\(gt\s+(\w+(?:\.\w+)*)\s+(\d+(?:\.\d+)?)\)/g,
(match, path, compareValue) => {
const value = this.getNestedValue(variables, path);
const numValue = parseFloat(compareValue);
return typeof value === 'number' && value > numValue ? 'true' : 'false';
}
);
// Handle gte (greater than or equal) helper function like (gte variable 0)
rendered = rendered.replace(
/\(gte\s+(\w+(?:\.\w+)*)\s+(\d+(?:\.\d+)?)\)/g,
(match, path, compareValue) => {
const value = this.getNestedValue(variables, path);
const numValue = parseFloat(compareValue);
return typeof value === 'number' && value >= numValue
? 'true'
: 'false';
}
);
// Handle conditionals with else {{#if variable}}...{{else}}...{{/if}}
rendered = rendered.replace(
/\{\{#if\s+([^}]+)\}\}([\s\S]*?)(?:\{\{else\}\}([\s\S]*?))?\{\{\/if\}\}/g,
(match, condition, trueContent, falseContent = '') => {
// Handle boolean values and helper function results
let value;
if (condition === 'true') {
value = true;
} else if (condition === 'false') {
value = false;
} else {
value = this.getNestedValue(variables, condition);
}
return value ? trueContent : falseContent;
}
);
// Handle each loops {{#each array}}...{{/each}}
rendered = rendered.replace(
/\{\{#each\s+(\w+(?:\.\w+)*)\}\}([\s\S]*?)\{\{\/each\}\}/g,
(match, path, content) => {
const array = this.getNestedValue(variables, path);
if (!Array.isArray(array)) return '';
return array
.map((item, index) => {
// Create a context with item properties and special variables
const itemContext = {
...variables,
...item,
'@index': index,
'@first': index === 0,
'@last': index === array.length - 1
};
// Recursively render the content with item context
return this.renderTemplate(content, itemContext);
})
.join('');
}
);
// Handle json helper {{{json variable}}} (triple braces for raw output)
rendered = rendered.replace(
/\{\{\{json\s+(\w+(?:\.\w+)*)\}\}\}/g,
(match, path) => {
const value = this.getNestedValue(variables, path);
return value !== undefined ? JSON.stringify(value, null, 2) : '';
}
);
// Handle variable substitution {{variable}}
rendered = rendered.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (match, path) => {
const value = this.getNestedValue(variables, path);
return value !== undefined ? value : '';
});
return rendered;
}
/**
* Get nested value from object using dot notation
* @private
*/
getNestedValue(obj, path) {
return path
.split('.')
.reduce(
(current, key) =>
current && current[key] !== undefined ? current[key] : undefined,
obj
);
}
/**
* Validate all prompt templates
*/
validateAllPrompts() {
const results = { total: 0, errors: [], valid: [] };
try {
const files = fs.readdirSync(this.promptsDir);
const promptFiles = files.filter((file) => file.endsWith('.json'));
for (const file of promptFiles) {
const promptId = file.replace('.json', '');
results.total++;
try {
this.loadTemplate(promptId);
results.valid.push(promptId);
} catch (error) {
results.errors.push(`${promptId}: ${error.message}`);
}
}
} catch (error) {
results.errors.push(
`Failed to read templates directory: ${error.message}`
);
}
return results;
}
/**
* List all available prompt templates
*/
listPrompts() {
try {
const files = fs.readdirSync(this.promptsDir);
const prompts = [];
for (const file of files) {
if (!file.endsWith('.json')) continue;
const promptId = file.replace('.json', '');
try {
const template = this.loadTemplate(promptId);
prompts.push({
id: template.id,
description: template.description,
version: template.version,
parameters: template.parameters,
tags: template.metadata?.tags || []
});
} catch (error) {
log('warn', `Failed to load template ${promptId}: ${error.message}`);
}
}
return prompts;
} catch (error) {
if (error.code === 'ENOENT') {
// Templates directory doesn't exist yet
return [];
}
throw error;
}
}
/**
* Validate template structure
*/
validateTemplate(templatePath) {
try {
const content = fs.readFileSync(templatePath, 'utf-8');
const template = JSON.parse(content);
// Check required fields
const required = ['id', 'version', 'description', 'prompts'];
for (const field of required) {
if (!template[field]) {
return { valid: false, error: `Missing required field: ${field}` };
}
}
// Check default prompt exists
if (!template.prompts.default) {
return { valid: false, error: 'Missing default prompt variant' };
}
// Check each variant has required fields
for (const [name, variant] of Object.entries(template.prompts)) {
if (!variant.system || !variant.user) {
return {
valid: false,
error: `Variant '${name}' missing system or user prompt`
};
}
}
// Schema validation if available
if (this.validatePrompt && this.validatePrompt !== true) {
const valid = this.validatePrompt(template);
if (!valid) {
const errors = this.validatePrompt.errors
.map((err) => `${err.instancePath || 'root'}: ${err.message}`)
.join(', ');
return { valid: false, error: `Schema validation failed: ${errors}` };
}
}
return { valid: true };
} catch (error) {
return { valid: false, error: error.message };
}
}
}
// Singleton instance
let promptManager = null;
/**
* Get or create the prompt manager instance
* @returns {PromptManager}
*/
export function getPromptManager() {
if (!promptManager) {
promptManager = new PromptManager();
}
return promptManager;
}