mirror of
https://github.com/bmadcode/BMAD-METHOD.git
synced 2025-12-29 16:14:59 +00:00
feat: implement granular step-file workflow architecture with multi-menu support
## Major Features Added - **Step-file workflow architecture**: Transform monolithic workflows into granular step files for improved LLM adherence and consistency - **Multi-menu handler system**: New `handler-multi.xml` enables grouped menu items with fuzzy matching - **Workflow compliance checker**: Added automated compliance validation for all workflows - **Create/edit agent workflows**: New structured workflows for agent creation and editing ## Workflow Enhancements - **Create-workflow**: Expanded from 6 to 14 detailed steps covering tools, design, compliance - **Granular step execution**: Each workflow step now has dedicated files for focused execution - **New documentation**: Added CSV data standards, intent vs prescriptive spectrum, and common tools reference ## Complete Migration Status - **4 workflows fully migrated**: `create-agent`, `edit-agent`, `create-workflow`, and `edit-workflow` now use the new granular step-file architecture - **Legacy transformation**: `edit-workflow` includes built-in capability to transform legacy single-file workflows into the new improved granular format - **Future cleanup**: Legacy versions will be removed in a future commit after validation ## Schema Updates - **Multi-menu support**: Updated agent schema to support `triggers` array for grouped menu items - **Legacy compatibility**: Maintains backward compatibility with single `trigger` field - **Discussion enhancements**: Added conversational_knowledge recommendation for discussion agents ## File Structure Changes - Added: `create-agent/`, `edit-agent/`, `edit-workflow/`, `workflow-compliance-check/` workflows - Added: Documentation standards and CSV reference files - Refactored: `create-workflow/steps/` with detailed granular step files ## Handler Improvements - Enhanced `handler-exec.xml` with clearer execution instructions - Improved data passing context for executed files - Better error handling and user guidance This architectural change significantly improves workflow execution consistency across all LLM models by breaking complex processes into manageable, focused steps. The edit-workflow transformation tool ensures smooth migration of existing workflows to the new format.
This commit is contained in:
@@ -1410,11 +1410,11 @@ class WebBundler {
|
||||
const menuItems = [];
|
||||
|
||||
if (!hasHelp) {
|
||||
menuItems.push(`${indent}<item cmd="*help">Show numbered menu</item>`);
|
||||
menuItems.push(`${indent}<item cmd="*menu">[M] Redisplay Menu Options</item>`);
|
||||
}
|
||||
|
||||
if (!hasExit) {
|
||||
menuItems.push(`${indent}<item cmd="*exit">Exit with confirmation</item>`);
|
||||
menuItems.push(`${indent}<item cmd="*dismiss">[D] Dismiss Agent</item>`);
|
||||
}
|
||||
|
||||
if (menuItems.length === 0) {
|
||||
|
||||
@@ -29,24 +29,52 @@ class AgentAnalyzer {
|
||||
// Track the menu item
|
||||
profile.menuItems.push(item);
|
||||
|
||||
// Check for each possible attribute
|
||||
if (item.workflow) {
|
||||
profile.usedAttributes.add('workflow');
|
||||
}
|
||||
if (item['validate-workflow']) {
|
||||
profile.usedAttributes.add('validate-workflow');
|
||||
}
|
||||
if (item.exec) {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
if (item.tmpl) {
|
||||
profile.usedAttributes.add('tmpl');
|
||||
}
|
||||
if (item.data) {
|
||||
profile.usedAttributes.add('data');
|
||||
}
|
||||
if (item.action) {
|
||||
profile.usedAttributes.add('action');
|
||||
// Check for multi format items
|
||||
if (item.multi && item.triggers) {
|
||||
profile.usedAttributes.add('multi');
|
||||
|
||||
// Also check attributes in nested handlers
|
||||
for (const triggerGroup of item.triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
if (Array.isArray(execArray)) {
|
||||
for (const exec of execArray) {
|
||||
if (exec.route) {
|
||||
// Check if route is a workflow or exec
|
||||
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
|
||||
profile.usedAttributes.add('workflow');
|
||||
} else {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
}
|
||||
if (exec.workflow) profile.usedAttributes.add('workflow');
|
||||
if (exec.action) profile.usedAttributes.add('action');
|
||||
if (exec.type && ['exec', 'action', 'workflow'].includes(exec.type)) {
|
||||
profile.usedAttributes.add(exec.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Check for each possible attribute in legacy items
|
||||
if (item.workflow) {
|
||||
profile.usedAttributes.add('workflow');
|
||||
}
|
||||
if (item['validate-workflow']) {
|
||||
profile.usedAttributes.add('validate-workflow');
|
||||
}
|
||||
if (item.exec) {
|
||||
profile.usedAttributes.add('exec');
|
||||
}
|
||||
if (item.tmpl) {
|
||||
profile.usedAttributes.add('tmpl');
|
||||
}
|
||||
if (item.data) {
|
||||
profile.usedAttributes.add('data');
|
||||
}
|
||||
if (item.action) {
|
||||
profile.usedAttributes.add('action');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -243,44 +243,143 @@ function buildPromptsXml(prompts) {
|
||||
|
||||
/**
|
||||
* Build menu XML section
|
||||
* Supports both legacy and multi format menu items
|
||||
* Multi items display as a single menu item with nested handlers
|
||||
* @param {Array} menuItems - Menu items
|
||||
* @returns {string} Menu XML
|
||||
*/
|
||||
function buildMenuXml(menuItems) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject *help first
|
||||
xml += ` <item cmd="*help">Show numbered menu</item>\n`;
|
||||
// Always inject menu display option first
|
||||
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
|
||||
|
||||
// Add user-defined menu items
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
for (const item of menuItems) {
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
// Handle multi format menu items with nested handlers
|
||||
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
|
||||
xml += ` <item type="multi">${escapeXml(item.multi)}\n`;
|
||||
xml += buildNestedHandlers(item.triggers);
|
||||
xml += ` </item>\n`;
|
||||
}
|
||||
// Handle legacy format menu items
|
||||
else if (item.trigger) {
|
||||
// For legacy items, keep using cmd with *<trigger> format
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
}
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
if (item.workflow) attrs.push(`workflow="${item.workflow}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
// Add handler attributes
|
||||
if (item.workflow) attrs.push(`workflow="${item.workflow}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
||||
xml += ` <item ${attrs.join(' ')}>${escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject *exit last
|
||||
xml += ` <item cmd="*exit">Exit with confirmation</item>\n`;
|
||||
// Always inject dismiss last
|
||||
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested handlers for multi format menu items
|
||||
* @param {Array} triggers - Triggers array from multi format
|
||||
* @returns {string} Handler XML
|
||||
*/
|
||||
function buildNestedHandlers(triggers) {
|
||||
let xml = '';
|
||||
|
||||
for (const triggerGroup of triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
// Build trigger with * prefix
|
||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
||||
|
||||
// Extract the relevant execution data
|
||||
const execData = processExecArray(execArray);
|
||||
|
||||
// For nested handlers in multi items, we use match attribute for fuzzy matching
|
||||
const attrs = [`match="${escapeXml(execData.description || '')}"`];
|
||||
|
||||
// Add handler attributes based on exec data
|
||||
if (execData.route) attrs.push(`exec="${execData.route}"`);
|
||||
if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`);
|
||||
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
|
||||
if (execData.action) attrs.push(`action="${execData.action}"`);
|
||||
if (execData.data) attrs.push(`data="${execData.data}"`);
|
||||
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
|
||||
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
|
||||
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
|
||||
|
||||
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the execution array from multi format triggers
|
||||
* Extracts relevant data for XML attributes
|
||||
* @param {Array} execArray - Array of execution objects
|
||||
* @returns {Object} Processed execution data
|
||||
*/
|
||||
function processExecArray(execArray) {
|
||||
const result = {
|
||||
description: '',
|
||||
route: null,
|
||||
workflow: null,
|
||||
data: null,
|
||||
action: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
if (!Array.isArray(execArray)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const exec of execArray) {
|
||||
if (exec.input) {
|
||||
// Use input as description if no explicit description is provided
|
||||
result.description = exec.input;
|
||||
}
|
||||
|
||||
if (exec.route) {
|
||||
// Determine if it's a workflow or exec based on file extension or context
|
||||
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
|
||||
result.workflow = exec.route;
|
||||
} else {
|
||||
result.route = exec.route;
|
||||
}
|
||||
}
|
||||
|
||||
if (exec.data !== null && exec.data !== undefined) {
|
||||
result.data = exec.data;
|
||||
}
|
||||
|
||||
if (exec.action) {
|
||||
result.action = exec.action;
|
||||
}
|
||||
|
||||
if (exec.type) {
|
||||
result.type = exec.type;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile agent YAML to proper XML format
|
||||
* @param {Object} agentYaml - Parsed and processed agent YAML
|
||||
|
||||
@@ -342,14 +342,15 @@ class YamlXmlBuilder {
|
||||
/**
|
||||
* Build menu XML section (renamed from commands for clarity)
|
||||
* Auto-injects *help and *exit, adds * prefix to all triggers
|
||||
* Supports both legacy format and new multi format with nested handlers
|
||||
* @param {Array} menuItems - Menu items from YAML
|
||||
* @param {boolean} forWebBundle - Whether building for web bundle
|
||||
*/
|
||||
buildCommandsXml(menuItems, forWebBundle = false) {
|
||||
let xml = ' <menu>\n';
|
||||
|
||||
// Always inject *help first
|
||||
xml += ` <item cmd="*help">Show numbered menu</item>\n`;
|
||||
// Always inject menu display option first
|
||||
xml += ` <item cmd="*menu">[M] Redisplay Menu Options</item>\n`;
|
||||
|
||||
// Add user-defined menu items with * prefix
|
||||
if (menuItems && menuItems.length > 0) {
|
||||
@@ -362,42 +363,140 @@ class YamlXmlBuilder {
|
||||
if (!forWebBundle && item['web-only'] === true) {
|
||||
continue;
|
||||
}
|
||||
// Build command attributes - add * prefix if not present
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
|
||||
// Handle multi format menu items with nested handlers
|
||||
if (item.multi && item.triggers && Array.isArray(item.triggers)) {
|
||||
xml += ` <item type="multi">${this.escapeXml(item.multi)}\n`;
|
||||
xml += this.buildNestedHandlers(item.triggers);
|
||||
xml += ` </item>\n`;
|
||||
}
|
||||
// Handle legacy format menu items
|
||||
else if (item.trigger) {
|
||||
// For legacy items, keep using cmd with *<trigger> format
|
||||
let trigger = item.trigger || '';
|
||||
if (!trigger.startsWith('*')) {
|
||||
trigger = '*' + trigger;
|
||||
}
|
||||
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
const attrs = [`cmd="${trigger}"`];
|
||||
|
||||
// Add handler attributes
|
||||
// If workflow-install exists, use its value for workflow attribute (vendoring)
|
||||
// workflow-install is build-time metadata - tells installer where to copy workflows
|
||||
// The final XML should only have workflow pointing to the install location
|
||||
if (item['workflow-install']) {
|
||||
attrs.push(`workflow="${item['workflow-install']}"`);
|
||||
} else if (item.workflow) {
|
||||
attrs.push(`workflow="${item.workflow}"`);
|
||||
// Add handler attributes
|
||||
// If workflow-install exists, use its value for workflow attribute (vendoring)
|
||||
// workflow-install is build-time metadata - tells installer where to copy workflows
|
||||
// The final XML should only have workflow pointing to the install location
|
||||
if (item['workflow-install']) {
|
||||
attrs.push(`workflow="${item['workflow-install']}"`);
|
||||
} else if (item.workflow) {
|
||||
attrs.push(`workflow="${item.workflow}"`);
|
||||
}
|
||||
|
||||
if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${this.escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
|
||||
if (item['validate-workflow']) attrs.push(`validate-workflow="${item['validate-workflow']}"`);
|
||||
if (item.exec) attrs.push(`exec="${item.exec}"`);
|
||||
if (item.tmpl) attrs.push(`tmpl="${item.tmpl}"`);
|
||||
if (item.data) attrs.push(`data="${item.data}"`);
|
||||
if (item.action) attrs.push(`action="${item.action}"`);
|
||||
|
||||
xml += ` <item ${attrs.join(' ')}>${this.escapeXml(item.description || '')}</item>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// Always inject *exit last
|
||||
xml += ` <item cmd="*exit">Exit with confirmation</item>\n`;
|
||||
// Always inject dismiss last
|
||||
xml += ` <item cmd="*dismiss">[D] Dismiss Agent</item>\n`;
|
||||
|
||||
xml += ' </menu>\n';
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build nested handlers for multi format menu items
|
||||
* @param {Array} triggers - Triggers array from multi format
|
||||
* @returns {string} Handler XML
|
||||
*/
|
||||
buildNestedHandlers(triggers) {
|
||||
let xml = '';
|
||||
|
||||
for (const triggerGroup of triggers) {
|
||||
for (const [triggerName, execArray] of Object.entries(triggerGroup)) {
|
||||
// Build trigger with * prefix
|
||||
let trigger = triggerName.startsWith('*') ? triggerName : '*' + triggerName;
|
||||
|
||||
// Extract the relevant execution data
|
||||
const execData = this.processExecArray(execArray);
|
||||
|
||||
// For nested handlers in multi items, we don't need cmd attribute
|
||||
// The match attribute will handle fuzzy matching
|
||||
const attrs = [`match="${this.escapeXml(execData.description || '')}"`];
|
||||
|
||||
// Add handler attributes based on exec data
|
||||
if (execData.route) attrs.push(`exec="${execData.route}"`);
|
||||
if (execData.workflow) attrs.push(`workflow="${execData.workflow}"`);
|
||||
if (execData['validate-workflow']) attrs.push(`validate-workflow="${execData['validate-workflow']}"`);
|
||||
if (execData.action) attrs.push(`action="${execData.action}"`);
|
||||
if (execData.data) attrs.push(`data="${execData.data}"`);
|
||||
if (execData.tmpl) attrs.push(`tmpl="${execData.tmpl}"`);
|
||||
// Only add type if it's not 'exec' (exec is already implied by the exec attribute)
|
||||
if (execData.type && execData.type !== 'exec') attrs.push(`type="${execData.type}"`);
|
||||
|
||||
xml += ` <handler ${attrs.join(' ')}></handler>\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return xml;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the execution array from multi format triggers
|
||||
* Extracts relevant data for XML attributes
|
||||
* @param {Array} execArray - Array of execution objects
|
||||
* @returns {Object} Processed execution data
|
||||
*/
|
||||
processExecArray(execArray) {
|
||||
const result = {
|
||||
description: '',
|
||||
route: null,
|
||||
workflow: null,
|
||||
data: null,
|
||||
action: null,
|
||||
type: null,
|
||||
};
|
||||
|
||||
if (!Array.isArray(execArray)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
for (const exec of execArray) {
|
||||
if (exec.input) {
|
||||
// Use input as description if no explicit description is provided
|
||||
result.description = exec.input;
|
||||
}
|
||||
|
||||
if (exec.route) {
|
||||
// Determine if it's a workflow or exec based on file extension or context
|
||||
if (exec.route.endsWith('.yaml') || exec.route.endsWith('.yml')) {
|
||||
result.workflow = exec.route;
|
||||
} else {
|
||||
result.route = exec.route;
|
||||
}
|
||||
}
|
||||
|
||||
if (exec.data !== null && exec.data !== undefined) {
|
||||
result.data = exec.data;
|
||||
}
|
||||
|
||||
if (exec.action) {
|
||||
result.action = exec.action;
|
||||
}
|
||||
|
||||
if (exec.type) {
|
||||
result.type = exec.type;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters
|
||||
*/
|
||||
|
||||
@@ -49,30 +49,70 @@ function agentSchema(options = {}) {
|
||||
|
||||
let index = 0;
|
||||
for (const item of value.agent.menu) {
|
||||
const triggerValue = item.trigger;
|
||||
// Handle legacy format with trigger field
|
||||
if (item.trigger) {
|
||||
const triggerValue = item.trigger;
|
||||
|
||||
if (!TRIGGER_PATTERN.test(triggerValue)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'trigger'],
|
||||
message: 'agent.menu[].trigger must be kebab-case (lowercase words separated by hyphen)',
|
||||
});
|
||||
return;
|
||||
if (!TRIGGER_PATTERN.test(triggerValue)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'trigger'],
|
||||
message: 'agent.menu[].trigger must be kebab-case (lowercase words separated by hyphen)',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenTriggers.has(triggerValue)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'trigger'],
|
||||
message: `agent.menu[].trigger duplicates "${triggerValue}" within the same agent`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenTriggers.add(triggerValue);
|
||||
}
|
||||
// Handle multi format with triggers array (new format)
|
||||
else if (item.triggers && Array.isArray(item.triggers)) {
|
||||
for (const triggerGroup of item.triggers) {
|
||||
for (const triggerKey of Object.keys(triggerGroup)) {
|
||||
if (!TRIGGER_PATTERN.test(triggerKey)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'triggers'],
|
||||
message: `agent.menu[].triggers key must be kebab-case (lowercase words separated by hyphen) - got "${triggerKey}"`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (seenTriggers.has(triggerKey)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'triggers'],
|
||||
message: `agent.menu[].triggers key duplicates "${triggerKey}" within the same agent`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenTriggers.add(triggerKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seenTriggers.has(triggerValue)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', index, 'trigger'],
|
||||
message: `agent.menu[].trigger duplicates "${triggerValue}" within the same agent`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
seenTriggers.add(triggerValue);
|
||||
index += 1;
|
||||
}
|
||||
})
|
||||
// Refinement: suggest conversational_knowledge when discussion is true
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.agent.discussion === true && !value.agent.conversational_knowledge) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'conversational_knowledge'],
|
||||
message: 'It is recommended to include conversational_knowledge when discussion is true',
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -89,6 +129,8 @@ function buildAgentSchema(expectedModule) {
|
||||
menu: z.array(buildMenuItemSchema()).min(1, { message: 'agent.menu must include at least one entry' }),
|
||||
prompts: z.array(buildPromptSchema()).optional(),
|
||||
webskip: z.boolean().optional(),
|
||||
discussion: z.boolean().optional(),
|
||||
conversational_knowledge: z.array(z.object({}).passthrough()).min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
}
|
||||
@@ -167,9 +209,11 @@ function buildPromptSchema() {
|
||||
|
||||
/**
|
||||
* Schema for individual menu entries ensuring they are actionable.
|
||||
* Supports both legacy format and new multi format.
|
||||
*/
|
||||
function buildMenuItemSchema() {
|
||||
return z
|
||||
// Legacy menu item format
|
||||
const legacyMenuItemSchema = z
|
||||
.object({
|
||||
trigger: createNonEmptyString('agent.menu[].trigger'),
|
||||
description: createNonEmptyString('agent.menu[].description'),
|
||||
@@ -179,11 +223,12 @@ function buildMenuItemSchema() {
|
||||
exec: createNonEmptyString('agent.menu[].exec').optional(),
|
||||
action: createNonEmptyString('agent.menu[].action').optional(),
|
||||
tmpl: createNonEmptyString('agent.menu[].tmpl').optional(),
|
||||
data: createNonEmptyString('agent.menu[].data').optional(),
|
||||
data: z.string().optional(),
|
||||
checklist: createNonEmptyString('agent.menu[].checklist').optional(),
|
||||
document: createNonEmptyString('agent.menu[].document').optional(),
|
||||
'ide-only': z.boolean().optional(),
|
||||
'web-only': z.boolean().optional(),
|
||||
discussion: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
@@ -199,6 +244,111 @@ function buildMenuItemSchema() {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Multi menu item format
|
||||
const multiMenuItemSchema = z
|
||||
.object({
|
||||
multi: createNonEmptyString('agent.menu[].multi'),
|
||||
triggers: z
|
||||
.array(z.object({}).passthrough())
|
||||
.refine(
|
||||
(triggers) => {
|
||||
// Each item in triggers array should be an object with exactly one key
|
||||
for (const trigger of triggers) {
|
||||
const keys = Object.keys(trigger);
|
||||
if (keys.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const execArray = trigger[keys[0]];
|
||||
if (!Array.isArray(execArray)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
const hasInput = execArray.some((item) => 'input' in item);
|
||||
const hasRouteOrAction = execArray.some((item) => 'route' in item || 'action' in item);
|
||||
|
||||
if (!hasInput) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If not TODO, must have route or action
|
||||
const isTodo = execArray.some((item) => item.route === 'TODO' || item.action === 'TODO');
|
||||
if (!isTodo && !hasRouteOrAction) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'agent.menu[].triggers must be an array of trigger objects with input and either route/action or TODO',
|
||||
},
|
||||
)
|
||||
.transform((triggers) => {
|
||||
// Validate and clean up the triggers
|
||||
for (const trigger of triggers) {
|
||||
const keys = Object.keys(trigger);
|
||||
if (keys.length !== 1) {
|
||||
throw new Error('Each trigger object must have exactly one key');
|
||||
}
|
||||
|
||||
const execArray = trigger[keys[0]];
|
||||
if (!Array.isArray(execArray)) {
|
||||
throw new TypeError(`Trigger "${keys[0]}" must be an array`);
|
||||
}
|
||||
|
||||
// Validate each item in the exec array
|
||||
for (const item of execArray) {
|
||||
if ('input' in item && typeof item.input !== 'string') {
|
||||
throw new Error('Input must be a string');
|
||||
}
|
||||
if ('route' in item && typeof item.route !== 'string' && item.route !== 'TODO') {
|
||||
throw new Error('Route must be a string or TODO');
|
||||
}
|
||||
if ('type' in item && !['exec', 'action', 'workflow', 'TODO'].includes(item.type)) {
|
||||
throw new Error('Type must be one of: exec, action, workflow, TODO');
|
||||
}
|
||||
}
|
||||
}
|
||||
return triggers;
|
||||
}),
|
||||
discussion: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
// Extract all trigger keys for validation
|
||||
const triggerKeys = [];
|
||||
for (const triggerGroup of value.triggers) {
|
||||
for (const key of Object.keys(triggerGroup)) {
|
||||
triggerKeys.push(key);
|
||||
|
||||
// Validate trigger key format
|
||||
if (!TRIGGER_PATTERN.test(key)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', 'triggers'],
|
||||
message: `Trigger key "${key}" must be kebab-case (lowercase words separated by hyphen)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for duplicates
|
||||
const seenTriggers = new Set();
|
||||
for (const triggerKey of triggerKeys) {
|
||||
if (seenTriggers.has(triggerKey)) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
path: ['agent', 'menu', 'triggers'],
|
||||
message: `Trigger key "${triggerKey}" is duplicated`,
|
||||
});
|
||||
}
|
||||
seenTriggers.add(triggerKey);
|
||||
}
|
||||
});
|
||||
|
||||
return z.union([legacyMenuItemSchema, multiMenuItemSchema]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user