BMAD-METHOD/.patch/573/agent.js.573.diff.txt

255 lines
9.0 KiB
Plaintext

diff --git a/tools/schema/agent.js b/tools/schema/agent.js
new file mode 100644
index 00000000..345f7217
--- /dev/null
+++ b/tools/schema/agent.js
@@ -0,0 +1,248 @@
+// Zod schema definition for *.agent.yaml files
+const assert = require('node:assert');
+const { z } = require('zod');
+
+const COMMAND_TARGET_KEYS = ['workflow', 'validate-workflow', 'exec', 'action', 'tmpl', 'data', 'run-workflow'];
+const TRIGGER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+
+// Public API ---------------------------------------------------------------
+
+/**
+ * Validate an agent YAML payload against the schema derived from its file location.
+ * Exposed as the single public entry point, so callers do not reach into schema internals.
+ *
+ * @param {string} filePath Path to the agent file (used to infer module scope).
+ * @param {unknown} agentYaml Parsed YAML content.
+ * @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
+ */
+function validateAgentFile(filePath, agentYaml) {
+ const expectedModule = normalizeModuleOption(deriveModuleFromPath(filePath));
+ const schema = agentSchema({ module: expectedModule });
+ return schema.safeParse(agentYaml);
+}
+
+module.exports = { validateAgentFile };
+
+// Internal helpers ---------------------------------------------------------
+
+/**
+ * Build a Zod schema for validating a single agent definition.
+ * The schema is generated per call so module-scoped agents can pass their expected
+ * module slug while core agents leave it undefined.
+ *
+ * @param {Object} [options]
+ * @param {string|null|undefined} [options.module] Module slug for module agents; omit or null for core agents.
+ * @returns {import('zod').ZodSchema} Configured Zod schema instance.
+ */
+function agentSchema(options = {}) {
+ const expectedModule = normalizeModuleOption(options.module);
+
+ return (
+ z
+ .object({
+ agent: buildAgentSchema(expectedModule),
+ })
+ .strict()
+ // Refinement: enforce trigger format and uniqueness rules after structural checks.
+ .superRefine((value, ctx) => {
+ const seenTriggers = new Set();
+
+ let index = 0;
+ for (const item of value.agent.menu) {
+ 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 (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;
+ }
+ })
+ );
+}
+
+/**
+ * Assemble the full agent schema using the module expectation provided by the caller.
+ * @param {string|null} expectedModule Trimmed module slug or null for core agents.
+ */
+function buildAgentSchema(expectedModule) {
+ return z
+ .object({
+ metadata: buildMetadataSchema(expectedModule),
+ persona: buildPersonaSchema(),
+ critical_actions: z.array(createNonEmptyString('agent.critical_actions[]')).optional(),
+ menu: z.array(buildMenuItemSchema()).min(1, { message: 'agent.menu must include at least one entry' }),
+ prompts: z.array(buildPromptSchema()).optional(),
+ })
+ .strict();
+}
+
+/**
+ * Validate metadata shape and cross-check module expectation against caller input.
+ * @param {string|null} expectedModule Trimmed module slug or null when core agent metadata is expected.
+ */
+function buildMetadataSchema(expectedModule) {
+ const schemaShape = {
+ id: createNonEmptyString('agent.metadata.id'),
+ name: createNonEmptyString('agent.metadata.name'),
+ title: createNonEmptyString('agent.metadata.title'),
+ icon: createNonEmptyString('agent.metadata.icon'),
+ module: createNonEmptyString('agent.metadata.module').optional(),
+ };
+
+ return (
+ z
+ .object(schemaShape)
+ .strict()
+ // Refinement: guard presence and correctness of metadata.module.
+ .superRefine((value, ctx) => {
+ const moduleValue = typeof value.module === 'string' ? value.module.trim() : null;
+
+ if (expectedModule && !moduleValue) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['module'],
+ message: 'module-scoped agents must declare agent.metadata.module',
+ });
+ } else if (!expectedModule && moduleValue) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['module'],
+ message: 'core agents must not include agent.metadata.module',
+ });
+ } else if (expectedModule && moduleValue !== expectedModule) {
+ ctx.addIssue({
+ code: 'custom',
+ path: ['module'],
+ message: `agent.metadata.module must equal "${expectedModule}"`,
+ });
+ }
+ })
+ );
+}
+
+function buildPersonaSchema() {
+ return z
+ .object({
+ role: createNonEmptyString('agent.persona.role'),
+ identity: createNonEmptyString('agent.persona.identity'),
+ communication_style: createNonEmptyString('agent.persona.communication_style'),
+ principles: z
+ .array(createNonEmptyString('agent.persona.principles[]'))
+ .min(1, { message: 'agent.persona.principles must include at least one entry' }),
+ })
+ .strict();
+}
+
+function buildPromptSchema() {
+ return z
+ .object({
+ id: createNonEmptyString('agent.prompts[].id'),
+ content: z.string().refine((value) => value.trim().length > 0, {
+ message: 'agent.prompts[].content must be a non-empty string',
+ }),
+ description: createNonEmptyString('agent.prompts[].description').optional(),
+ })
+ .strict();
+}
+
+/**
+ * Schema for individual menu entries ensuring they are actionable.
+ */
+function buildMenuItemSchema() {
+ return z
+ .object({
+ trigger: createNonEmptyString('agent.menu[].trigger'),
+ description: createNonEmptyString('agent.menu[].description'),
+ workflow: createNonEmptyString('agent.menu[].workflow').optional(),
+ 'validate-workflow': createNonEmptyString('agent.menu[].validate-workflow').optional(),
+ exec: createNonEmptyString('agent.menu[].exec').optional(),
+ action: createNonEmptyString('agent.menu[].action').optional(),
+ tmpl: createNonEmptyString('agent.menu[].tmpl').optional(),
+ data: createNonEmptyString('agent.menu[].data').optional(),
+ 'run-workflow': createNonEmptyString('agent.menu[].run-workflow').optional(),
+ })
+ .strict()
+ .superRefine((value, ctx) => {
+ const hasCommandTarget = COMMAND_TARGET_KEYS.some((key) => {
+ const commandValue = value[key];
+ return typeof commandValue === 'string' && commandValue.trim().length > 0;
+ });
+
+ if (!hasCommandTarget) {
+ ctx.addIssue({
+ code: 'custom',
+ message: 'agent.menu[] entries must include at least one command target field',
+ });
+ }
+ });
+}
+
+/**
+ * Derive the expected module slug from a file path residing under src/modules/<module>/agents/.
+ * @param {string} filePath Absolute or relative agent path.
+ * @returns {string|null} Module slug if identifiable and path is well-formed, otherwise null.
+ */
+function deriveModuleFromPath(filePath) {
+ assert(filePath, 'validateAgentFile expects filePath to be provided');
+ assert(typeof filePath === 'string', 'validateAgentFile expects filePath to be a string');
+ assert(filePath.startsWith('src/'), 'validateAgentFile expects filePath to start with "src/"');
+
+ const parts = filePath.split('/');
+ const modulesIndex = parts.indexOf('modules');
+
+ // If no 'modules' found in path, it's a core agent
+ if (modulesIndex === -1) {
+ return null;
+ }
+
+ // If 'modules' is the last part or there's nothing after it, return null (core agent)
+ if (modulesIndex + 1 >= parts.length) {
+ return null;
+ }
+
+ // Get the module name
+ const module = parts[modulesIndex + 1];
+
+ // Validate that the path is well-formed: must have 'agents' directory after module name
+ // Path should be: src/modules/<module>/agents/...
+ if (modulesIndex + 2 >= parts.length || parts[modulesIndex + 2] !== 'agents') {
+ // Malformed path (no /agents/ subdirectory), treat as core agent
+ return null;
+ }
+
+ // Return module name if non-empty, otherwise null
+ return module && module.length > 0 ? module : null;
+}
+
+function normalizeModuleOption(moduleOption) {
+ if (typeof moduleOption !== 'string') {
+ return null;
+ }
+
+ const trimmed = moduleOption.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+// Primitive validators -----------------------------------------------------
+
+function createNonEmptyString(label) {
+ return z.string().refine((value) => value.trim().length > 0, {
+ message: `${label} must be a non-empty string`,
+ });
+}