shipdeck
Version:
Ship MVPs in 48 hours. Fix bugs in 30 seconds. The command deck for developers who ship.
348 lines (300 loc) • 9.62 kB
JavaScript
/**
* Template Processor for Shipdeck Ultimate
* Loads, parses, and processes YAML templates for agent intelligence
*/
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
class TemplateProcessor {
constructor() {
this.templateCache = new Map();
this.baseTemplatePath = path.join(__dirname, '..', 'templates');
this.rulesPath = path.join(__dirname, '..', 'rules');
this.prepTemplatePath = path.join(__dirname, '..', 'prep-templates');
}
/**
* Load a template from file system
* @param {string} templatePath - Path relative to templates directory
* @returns {Object} Parsed template object
*/
async loadTemplate(templatePath) {
const cacheKey = templatePath;
// Check cache first
if (this.templateCache.has(cacheKey)) {
return this.templateCache.get(cacheKey);
}
const fullPath = path.join(this.baseTemplatePath, templatePath);
try {
const content = fs.readFileSync(fullPath, 'utf8');
let template;
if (fullPath.endsWith('.yaml') || fullPath.endsWith('.yml')) {
template = yaml.load(content);
} else if (fullPath.endsWith('.md')) {
template = this.parseMarkdownTemplate(content);
} else if (fullPath.endsWith('.json')) {
template = JSON.parse(content);
} else {
throw new Error(`Unsupported template format: ${path.extname(fullPath)}`);
}
// Enhance template with metadata
template.metadata = {
path: templatePath,
loadedAt: new Date().toISOString(),
type: this.detectTemplateType(template)
};
// Cache the template
this.templateCache.set(cacheKey, template);
return template;
} catch (error) {
console.error(`Failed to load template ${templatePath}:`, error.message);
throw error;
}
}
/**
* Parse markdown template format (legacy ai-task-templates)
* @param {string} content - Markdown content
* @returns {Object} Parsed template structure
*/
parseMarkdownTemplate(content) {
const template = {
sections: {},
variables: {},
steps: []
};
// Parse sections (## headers)
const sectionRegex = /^##\s+(.+)$/gm;
const sections = content.split(sectionRegex);
for (let i = 1; i < sections.length; i += 2) {
const sectionName = sections[i].trim();
const sectionContent = sections[i + 1].trim();
template.sections[sectionName.toLowerCase().replace(/\s+/g, '_')] = sectionContent;
}
// Parse variables ({{variable}})
const variableRegex = /\{\{(\w+)\}\}/g;
let match;
while ((match = variableRegex.exec(content)) !== null) {
template.variables[match[1]] = null;
}
// Parse steps (numbered lists)
const stepRegex = /^\d+\.\s+(.+)$/gm;
while ((match = stepRegex.exec(content)) !== null) {
template.steps.push({
action: match[1],
completed: false
});
}
return template;
}
/**
* Detect template type based on structure
* @param {Object} template - Template object
* @returns {string} Template type
*/
detectTemplateType(template) {
if (template.lifecycle) return 'lifecycle';
if (template.agent) return 'agent';
if (template.workflow) return 'workflow';
if (template.rules) return 'rules';
if (template.prep) return 'prep';
return 'generic';
}
/**
* Fill template parameters with context
* @param {Object} template - Template object
* @param {Object} context - Context with values
* @returns {Object} Filled template
*/
fillParameters(template, context) {
const filled = JSON.parse(JSON.stringify(template)); // Deep clone
// Fill variables
if (filled.variables) {
for (const [key, value] of Object.entries(context)) {
if (filled.variables[key] !== undefined) {
filled.variables[key] = value;
}
}
}
// Replace placeholders in all string values
const replacePlaceholders = (obj) => {
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string') {
obj[key] = value.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
return context[varName] || match;
});
} else if (typeof value === 'object' && value !== null) {
replacePlaceholders(value);
}
}
};
replacePlaceholders(filled);
return filled;
}
/**
* Create workflow instance from template
* @param {Object} template - Template object
* @param {Object} params - Parameters for workflow
* @returns {Object} Workflow instance
*/
createWorkflow(template, params) {
const workflow = {
id: this.generateWorkflowId(),
templateId: template.metadata?.path || 'unknown',
status: 'initialized',
createdAt: new Date().toISOString(),
params: params,
currentStep: 0,
steps: [],
results: {},
context: {}
};
// Build workflow steps based on template type
if (template.steps && Array.isArray(template.steps)) {
workflow.steps = template.steps.map(step => ({
...step,
status: 'pending',
startedAt: null,
completedAt: null,
result: null
}));
} else if (template.phases) {
// Lifecycle template with phases
for (const phase of template.phases) {
workflow.steps.push({
name: phase.name,
description: phase.description,
agents: phase.agents || [],
status: 'pending',
parallel: phase.parallel || false
});
}
}
return workflow;
}
/**
* Load all rules from cursor rules directory
* @returns {Array} Array of rule objects
*/
async loadRules() {
const rules = [];
const rulesDir = this.rulesPath;
if (!fs.existsSync(rulesDir)) {
console.warn('Rules directory not found:', rulesDir);
return rules;
}
const categories = fs.readdirSync(rulesDir)
.filter(file => fs.statSync(path.join(rulesDir, file)).isDirectory());
for (const category of categories) {
const categoryPath = path.join(rulesDir, category);
const ruleFiles = fs.readdirSync(categoryPath)
.filter(file => file.endsWith('.mdc') || file.endsWith('.md'));
for (const ruleFile of ruleFiles) {
const rulePath = path.join(categoryPath, ruleFile);
const content = fs.readFileSync(rulePath, 'utf8');
rules.push({
category: category,
name: path.basename(ruleFile, path.extname(ruleFile)),
path: rulePath,
content: content,
active: true
});
}
}
return rules;
}
/**
* Load prep templates for project initialization
* @returns {Array} Array of prep template objects
*/
async loadPrepTemplates() {
const prepTemplates = [];
const prepDir = this.prepTemplatePath;
if (!fs.existsSync(prepDir)) {
console.warn('Prep templates directory not found:', prepDir);
return prepTemplates;
}
const files = fs.readdirSync(prepDir)
.filter(file => file.match(/^\d+_/))
.sort();
for (const file of files) {
const filePath = path.join(prepDir, file);
const content = fs.readFileSync(filePath, 'utf8');
const order = parseInt(file.match(/^(\d+)_/)[1]);
const name = file.replace(/^\d+_/, '').replace(/\.\w+$/, '');
prepTemplates.push({
order: order,
name: name,
file: file,
path: filePath,
content: content
});
}
return prepTemplates;
}
/**
* Generate unique workflow ID
* @returns {string} Workflow ID
*/
generateWorkflowId() {
return `wf-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
/**
* Validate template structure
* @param {Object} template - Template to validate
* @returns {Object} Validation result
*/
validateTemplate(template) {
const errors = [];
const warnings = [];
// Check required fields based on type
const type = template.metadata?.type || this.detectTemplateType(template);
switch (type) {
case 'lifecycle':
if (!template.phases || !Array.isArray(template.phases)) {
errors.push('Lifecycle template must have phases array');
}
break;
case 'agent':
if (!template.agent || !template.agent.name) {
errors.push('Agent template must have agent.name');
}
break;
case 'workflow':
if (!template.steps || !Array.isArray(template.steps)) {
errors.push('Workflow template must have steps array');
}
break;
}
// Check for undefined variables
if (template.variables) {
for (const [key, value] of Object.entries(template.variables)) {
if (value === null || value === undefined) {
warnings.push(`Variable ${key} is not initialized`);
}
}
}
return {
valid: errors.length === 0,
errors: errors,
warnings: warnings
};
}
/**
* Clear template cache
*/
clearCache() {
this.templateCache.clear();
console.log('Template cache cleared');
}
/**
* Get template statistics
* @returns {Object} Statistics about loaded templates
*/
getStats() {
return {
cachedTemplates: this.templateCache.size,
cacheKeys: Array.from(this.templateCache.keys()),
memoryUsage: process.memoryUsage()
};
}
}
module.exports = TemplateProcessor;