bmad-method 6.2.1-next.11 → 6.2.1-next.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -7
- package/tools/schema/agent.js +0 -489
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-method",
|
|
4
|
-
"version": "6.2.1-next.
|
|
4
|
+
"version": "6.2.1-next.12",
|
|
5
5
|
"description": "Breakthrough Method of Agile AI-driven Development",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"agile",
|
|
@@ -39,15 +39,12 @@
|
|
|
39
39
|
"lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix",
|
|
40
40
|
"lint:md": "markdownlint-cli2 \"**/*.md\"",
|
|
41
41
|
"prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0",
|
|
42
|
-
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run
|
|
42
|
+
"quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run validate:refs",
|
|
43
43
|
"rebundle": "node tools/cli/bundlers/bundle-web.js rebundle",
|
|
44
|
-
"test": "npm run test:
|
|
45
|
-
"test:coverage": "c8 --reporter=text --reporter=html npm run test:schemas",
|
|
44
|
+
"test": "npm run test:refs && npm run test:install && npm run lint && npm run lint:md && npm run format:check",
|
|
46
45
|
"test:install": "node test/test-installation-components.js",
|
|
47
46
|
"test:refs": "node test/test-file-refs-csv.js",
|
|
48
|
-
"
|
|
49
|
-
"validate:refs": "node tools/validate-file-refs.js --strict",
|
|
50
|
-
"validate:schemas": "node tools/validate-agent-schema.js"
|
|
47
|
+
"validate:refs": "node tools/validate-file-refs.js --strict"
|
|
51
48
|
},
|
|
52
49
|
"lint-staged": {
|
|
53
50
|
"*.{js,cjs,mjs}": [
|
package/tools/schema/agent.js
DELETED
|
@@ -1,489 +0,0 @@
|
|
|
1
|
-
// Zod schema definition for *.agent.yaml files
|
|
2
|
-
const assert = require('node:assert');
|
|
3
|
-
const { z } = require('zod');
|
|
4
|
-
|
|
5
|
-
const COMMAND_TARGET_KEYS = ['validate-workflow', 'exec', 'action', 'tmpl', 'data'];
|
|
6
|
-
const TRIGGER_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7
|
-
const COMPOUND_TRIGGER_PATTERN = /^([A-Z]{1,3}) or fuzzy match on ([a-z0-9]+(?:-[a-z0-9]+)*)$/;
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Derive the expected shortcut from a kebab-case trigger.
|
|
11
|
-
* - Single word: first letter (e.g., "help" → "H")
|
|
12
|
-
* - Multi-word: first letter of first two words (e.g., "tech-spec" → "TS")
|
|
13
|
-
* @param {string} kebabTrigger The kebab-case trigger name.
|
|
14
|
-
* @returns {string} The expected uppercase shortcut.
|
|
15
|
-
*/
|
|
16
|
-
function deriveShortcutFromKebab(kebabTrigger) {
|
|
17
|
-
const words = kebabTrigger.split('-');
|
|
18
|
-
if (words.length === 1) {
|
|
19
|
-
return words[0][0].toUpperCase();
|
|
20
|
-
}
|
|
21
|
-
return (words[0][0] + words[1][0]).toUpperCase();
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Parse and validate a compound trigger string.
|
|
26
|
-
* Format: "<SHORTCUT> or fuzzy match on <kebab-case>"
|
|
27
|
-
* @param {string} triggerValue The trigger string to parse.
|
|
28
|
-
* @returns {{ valid: boolean, shortcut?: string, kebabTrigger?: string, error?: string }}
|
|
29
|
-
*/
|
|
30
|
-
function parseCompoundTrigger(triggerValue) {
|
|
31
|
-
const match = COMPOUND_TRIGGER_PATTERN.exec(triggerValue);
|
|
32
|
-
if (!match) {
|
|
33
|
-
return { valid: false, error: 'invalid compound trigger format' };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const [, shortcut, kebabTrigger] = match;
|
|
37
|
-
|
|
38
|
-
return { valid: true, shortcut, kebabTrigger };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Public API ---------------------------------------------------------------
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
* Validate an agent YAML payload against the schema derived from its file location.
|
|
45
|
-
* Exposed as the single public entry point, so callers do not reach into schema internals.
|
|
46
|
-
*
|
|
47
|
-
* @param {string} filePath Path to the agent file (used to infer module scope).
|
|
48
|
-
* @param {unknown} agentYaml Parsed YAML content.
|
|
49
|
-
* @returns {import('zod').SafeParseReturnType<unknown, unknown>} SafeParse result.
|
|
50
|
-
*/
|
|
51
|
-
function validateAgentFile(filePath, agentYaml) {
|
|
52
|
-
const expectedModule = deriveModuleFromPath(filePath);
|
|
53
|
-
const schema = agentSchema({ module: expectedModule });
|
|
54
|
-
return schema.safeParse(agentYaml);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
module.exports = { validateAgentFile };
|
|
58
|
-
|
|
59
|
-
// Internal helpers ---------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Build a Zod schema for validating a single agent definition.
|
|
63
|
-
* The schema is generated per call so module-scoped agents can pass their expected
|
|
64
|
-
* module slug while core agents leave it undefined.
|
|
65
|
-
*
|
|
66
|
-
* @param {Object} [options]
|
|
67
|
-
* @param {string|null|undefined} [options.module] Module slug for module agents; omit or null for core agents.
|
|
68
|
-
* @returns {import('zod').ZodSchema} Configured Zod schema instance.
|
|
69
|
-
*/
|
|
70
|
-
function agentSchema(options = {}) {
|
|
71
|
-
const expectedModule = normalizeModuleOption(options.module);
|
|
72
|
-
|
|
73
|
-
return (
|
|
74
|
-
z
|
|
75
|
-
.object({
|
|
76
|
-
agent: buildAgentSchema(expectedModule),
|
|
77
|
-
})
|
|
78
|
-
.strict()
|
|
79
|
-
// Refinement: enforce trigger format and uniqueness rules after structural checks.
|
|
80
|
-
.superRefine((value, ctx) => {
|
|
81
|
-
const seenTriggers = new Set();
|
|
82
|
-
|
|
83
|
-
let index = 0;
|
|
84
|
-
for (const item of value.agent.menu) {
|
|
85
|
-
// Handle legacy format with trigger field
|
|
86
|
-
if (item.trigger) {
|
|
87
|
-
const triggerValue = item.trigger;
|
|
88
|
-
let canonicalTrigger = triggerValue;
|
|
89
|
-
|
|
90
|
-
// Check if it's a compound trigger (contains " or ")
|
|
91
|
-
if (triggerValue.includes(' or ')) {
|
|
92
|
-
const result = parseCompoundTrigger(triggerValue);
|
|
93
|
-
if (!result.valid) {
|
|
94
|
-
ctx.addIssue({
|
|
95
|
-
code: 'custom',
|
|
96
|
-
path: ['agent', 'menu', index, 'trigger'],
|
|
97
|
-
message: `agent.menu[].trigger compound format error: ${result.error}`,
|
|
98
|
-
});
|
|
99
|
-
return;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Validate that shortcut matches description brackets
|
|
103
|
-
const descriptionMatch = item.description?.match(/^\[([A-Z]{1,3})\]/);
|
|
104
|
-
if (!descriptionMatch) {
|
|
105
|
-
ctx.addIssue({
|
|
106
|
-
code: 'custom',
|
|
107
|
-
path: ['agent', 'menu', index, 'description'],
|
|
108
|
-
message: `agent.menu[].description must start with [SHORTCUT] where SHORTCUT matches the trigger shortcut "${result.shortcut}"`,
|
|
109
|
-
});
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const descriptionShortcut = descriptionMatch[1];
|
|
114
|
-
if (descriptionShortcut !== result.shortcut) {
|
|
115
|
-
ctx.addIssue({
|
|
116
|
-
code: 'custom',
|
|
117
|
-
path: ['agent', 'menu', index, 'description'],
|
|
118
|
-
message: `agent.menu[].description shortcut "[${descriptionShortcut}]" must match trigger shortcut "${result.shortcut}"`,
|
|
119
|
-
});
|
|
120
|
-
return;
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
canonicalTrigger = result.kebabTrigger;
|
|
124
|
-
} else if (!TRIGGER_PATTERN.test(triggerValue)) {
|
|
125
|
-
ctx.addIssue({
|
|
126
|
-
code: 'custom',
|
|
127
|
-
path: ['agent', 'menu', index, 'trigger'],
|
|
128
|
-
message: 'agent.menu[].trigger must be kebab-case (lowercase words separated by hyphen)',
|
|
129
|
-
});
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (seenTriggers.has(canonicalTrigger)) {
|
|
134
|
-
ctx.addIssue({
|
|
135
|
-
code: 'custom',
|
|
136
|
-
path: ['agent', 'menu', index, 'trigger'],
|
|
137
|
-
message: `agent.menu[].trigger duplicates "${canonicalTrigger}" within the same agent`,
|
|
138
|
-
});
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
seenTriggers.add(canonicalTrigger);
|
|
143
|
-
}
|
|
144
|
-
// Handle multi format with triggers array (new format)
|
|
145
|
-
else if (item.triggers && Array.isArray(item.triggers)) {
|
|
146
|
-
for (const [triggerIndex, triggerItem] of item.triggers.entries()) {
|
|
147
|
-
let triggerName = null;
|
|
148
|
-
|
|
149
|
-
// Extract trigger name from all three formats
|
|
150
|
-
if (triggerItem.trigger) {
|
|
151
|
-
// Format 1: Simple flat format with trigger field
|
|
152
|
-
triggerName = triggerItem.trigger;
|
|
153
|
-
} else {
|
|
154
|
-
// Format 2a or 2b: Object-key format
|
|
155
|
-
const keys = Object.keys(triggerItem);
|
|
156
|
-
if (keys.length === 1 && keys[0] !== 'trigger') {
|
|
157
|
-
triggerName = keys[0];
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
if (triggerName) {
|
|
162
|
-
if (!TRIGGER_PATTERN.test(triggerName)) {
|
|
163
|
-
ctx.addIssue({
|
|
164
|
-
code: 'custom',
|
|
165
|
-
path: ['agent', 'menu', index, 'triggers', triggerIndex],
|
|
166
|
-
message: `agent.menu[].triggers[] must be kebab-case (lowercase words separated by hyphen) - got "${triggerName}"`,
|
|
167
|
-
});
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
if (seenTriggers.has(triggerName)) {
|
|
172
|
-
ctx.addIssue({
|
|
173
|
-
code: 'custom',
|
|
174
|
-
path: ['agent', 'menu', index, 'triggers', triggerIndex],
|
|
175
|
-
message: `agent.menu[].triggers[] duplicates "${triggerName}" within the same agent`,
|
|
176
|
-
});
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
seenTriggers.add(triggerName);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
index += 1;
|
|
186
|
-
}
|
|
187
|
-
})
|
|
188
|
-
// Refinement: suggest conversational_knowledge when discussion is true
|
|
189
|
-
.superRefine((value, ctx) => {
|
|
190
|
-
if (value.agent.discussion === true && !value.agent.conversational_knowledge) {
|
|
191
|
-
ctx.addIssue({
|
|
192
|
-
code: 'custom',
|
|
193
|
-
path: ['agent', 'conversational_knowledge'],
|
|
194
|
-
message: 'It is recommended to include conversational_knowledge when discussion is true',
|
|
195
|
-
});
|
|
196
|
-
}
|
|
197
|
-
})
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* Assemble the full agent schema using the module expectation provided by the caller.
|
|
203
|
-
* @param {string|null} expectedModule Trimmed module slug or null for core agents.
|
|
204
|
-
*/
|
|
205
|
-
function buildAgentSchema(expectedModule) {
|
|
206
|
-
return z
|
|
207
|
-
.object({
|
|
208
|
-
metadata: buildMetadataSchema(expectedModule),
|
|
209
|
-
persona: buildPersonaSchema(),
|
|
210
|
-
critical_actions: z.array(createNonEmptyString('agent.critical_actions[]')).optional(),
|
|
211
|
-
menu: z.array(buildMenuItemSchema()).min(1, { message: 'agent.menu must include at least one entry' }),
|
|
212
|
-
prompts: z.array(buildPromptSchema()).optional(),
|
|
213
|
-
discussion: z.boolean().optional(),
|
|
214
|
-
conversational_knowledge: z.array(z.object({}).passthrough()).min(1).optional(),
|
|
215
|
-
})
|
|
216
|
-
.strict();
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
/**
|
|
220
|
-
* Validate metadata shape.
|
|
221
|
-
* @param {string|null} expectedModule Trimmed module slug or null when core agent metadata is expected.
|
|
222
|
-
* Note: Module field is optional and can be any value - no validation against path.
|
|
223
|
-
*/
|
|
224
|
-
function buildMetadataSchema(expectedModule) {
|
|
225
|
-
const schemaShape = {
|
|
226
|
-
id: createNonEmptyString('agent.metadata.id'),
|
|
227
|
-
name: createNonEmptyString('agent.metadata.name'),
|
|
228
|
-
title: createNonEmptyString('agent.metadata.title'),
|
|
229
|
-
icon: createNonEmptyString('agent.metadata.icon'),
|
|
230
|
-
module: createNonEmptyString('agent.metadata.module').optional(),
|
|
231
|
-
capabilities: createNonEmptyString('agent.metadata.capabilities').optional(),
|
|
232
|
-
hasSidecar: z.boolean(),
|
|
233
|
-
};
|
|
234
|
-
|
|
235
|
-
return z.object(schemaShape).strict();
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function buildPersonaSchema() {
|
|
239
|
-
return z
|
|
240
|
-
.object({
|
|
241
|
-
role: createNonEmptyString('agent.persona.role'),
|
|
242
|
-
identity: createNonEmptyString('agent.persona.identity'),
|
|
243
|
-
communication_style: createNonEmptyString('agent.persona.communication_style'),
|
|
244
|
-
principles: z.union([
|
|
245
|
-
createNonEmptyString('agent.persona.principles'),
|
|
246
|
-
z
|
|
247
|
-
.array(createNonEmptyString('agent.persona.principles[]'))
|
|
248
|
-
.min(1, { message: 'agent.persona.principles must include at least one entry' }),
|
|
249
|
-
]),
|
|
250
|
-
})
|
|
251
|
-
.strict();
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function buildPromptSchema() {
|
|
255
|
-
return z
|
|
256
|
-
.object({
|
|
257
|
-
id: createNonEmptyString('agent.prompts[].id'),
|
|
258
|
-
content: z.string().refine((value) => value.trim().length > 0, {
|
|
259
|
-
message: 'agent.prompts[].content must be a non-empty string',
|
|
260
|
-
}),
|
|
261
|
-
description: createNonEmptyString('agent.prompts[].description').optional(),
|
|
262
|
-
})
|
|
263
|
-
.strict();
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
/**
|
|
267
|
-
* Schema for individual menu entries ensuring they are actionable.
|
|
268
|
-
* Supports both legacy format and new multi format.
|
|
269
|
-
*/
|
|
270
|
-
function buildMenuItemSchema() {
|
|
271
|
-
// Legacy menu item format
|
|
272
|
-
const legacyMenuItemSchema = z
|
|
273
|
-
.object({
|
|
274
|
-
trigger: createNonEmptyString('agent.menu[].trigger'),
|
|
275
|
-
description: createNonEmptyString('agent.menu[].description'),
|
|
276
|
-
'validate-workflow': createNonEmptyString('agent.menu[].validate-workflow').optional(),
|
|
277
|
-
exec: createNonEmptyString('agent.menu[].exec').optional(),
|
|
278
|
-
action: createNonEmptyString('agent.menu[].action').optional(),
|
|
279
|
-
tmpl: createNonEmptyString('agent.menu[].tmpl').optional(),
|
|
280
|
-
data: z.string().optional(),
|
|
281
|
-
checklist: createNonEmptyString('agent.menu[].checklist').optional(),
|
|
282
|
-
document: createNonEmptyString('agent.menu[].document').optional(),
|
|
283
|
-
'ide-only': z.boolean().optional(),
|
|
284
|
-
'web-only': z.boolean().optional(),
|
|
285
|
-
discussion: z.boolean().optional(),
|
|
286
|
-
})
|
|
287
|
-
.strict()
|
|
288
|
-
.superRefine((value, ctx) => {
|
|
289
|
-
const hasCommandTarget = COMMAND_TARGET_KEYS.some((key) => {
|
|
290
|
-
const commandValue = value[key];
|
|
291
|
-
return typeof commandValue === 'string' && commandValue.trim().length > 0;
|
|
292
|
-
});
|
|
293
|
-
|
|
294
|
-
if (!hasCommandTarget) {
|
|
295
|
-
ctx.addIssue({
|
|
296
|
-
code: 'custom',
|
|
297
|
-
message: 'agent.menu[] entries must include at least one command target field',
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
|
|
302
|
-
// Multi menu item format
|
|
303
|
-
const multiMenuItemSchema = z
|
|
304
|
-
.object({
|
|
305
|
-
multi: createNonEmptyString('agent.menu[].multi'),
|
|
306
|
-
triggers: z
|
|
307
|
-
.array(
|
|
308
|
-
z.union([
|
|
309
|
-
// Format 1: Simple flat format (has trigger field)
|
|
310
|
-
z
|
|
311
|
-
.object({
|
|
312
|
-
trigger: z.string(),
|
|
313
|
-
input: createNonEmptyString('agent.menu[].triggers[].input'),
|
|
314
|
-
route: createNonEmptyString('agent.menu[].triggers[].route').optional(),
|
|
315
|
-
action: createNonEmptyString('agent.menu[].triggers[].action').optional(),
|
|
316
|
-
data: z.string().optional(),
|
|
317
|
-
type: z.enum(['exec', 'action', 'workflow']).optional(),
|
|
318
|
-
})
|
|
319
|
-
.strict()
|
|
320
|
-
.refine((data) => data.trigger, { message: 'Must have trigger field' })
|
|
321
|
-
.superRefine((value, ctx) => {
|
|
322
|
-
// Must have either route or action (or both)
|
|
323
|
-
if (!value.route && !value.action) {
|
|
324
|
-
ctx.addIssue({
|
|
325
|
-
code: 'custom',
|
|
326
|
-
message: 'agent.menu[].triggers[] must have either route or action (or both)',
|
|
327
|
-
});
|
|
328
|
-
}
|
|
329
|
-
}),
|
|
330
|
-
// Format 2a: Object with array format (like bmad-builder.agent.yaml)
|
|
331
|
-
z
|
|
332
|
-
.object({})
|
|
333
|
-
.passthrough()
|
|
334
|
-
.refine(
|
|
335
|
-
(value) => {
|
|
336
|
-
const keys = Object.keys(value);
|
|
337
|
-
if (keys.length !== 1) return false;
|
|
338
|
-
const triggerItems = value[keys[0]];
|
|
339
|
-
return Array.isArray(triggerItems);
|
|
340
|
-
},
|
|
341
|
-
{ message: 'Must be object with single key pointing to array' },
|
|
342
|
-
)
|
|
343
|
-
.superRefine((value, ctx) => {
|
|
344
|
-
const triggerName = Object.keys(value)[0];
|
|
345
|
-
const triggerItems = value[triggerName];
|
|
346
|
-
|
|
347
|
-
if (!Array.isArray(triggerItems)) {
|
|
348
|
-
ctx.addIssue({
|
|
349
|
-
code: 'custom',
|
|
350
|
-
message: `Trigger "${triggerName}" must be an array of items`,
|
|
351
|
-
});
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
// Check required fields in the array
|
|
356
|
-
const hasInput = triggerItems.some((item) => 'input' in item);
|
|
357
|
-
const hasRouteOrAction = triggerItems.some((item) => 'route' in item || 'action' in item);
|
|
358
|
-
|
|
359
|
-
if (!hasInput) {
|
|
360
|
-
ctx.addIssue({
|
|
361
|
-
code: 'custom',
|
|
362
|
-
message: `Trigger "${triggerName}" must have an input field`,
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (!hasRouteOrAction) {
|
|
367
|
-
ctx.addIssue({
|
|
368
|
-
code: 'custom',
|
|
369
|
-
message: `Trigger "${triggerName}" must have a route or action field`,
|
|
370
|
-
});
|
|
371
|
-
}
|
|
372
|
-
}),
|
|
373
|
-
// Format 2b: Object with direct fields (like analyst.agent.yaml)
|
|
374
|
-
z
|
|
375
|
-
.object({})
|
|
376
|
-
.passthrough()
|
|
377
|
-
.refine(
|
|
378
|
-
(value) => {
|
|
379
|
-
const keys = Object.keys(value);
|
|
380
|
-
if (keys.length !== 1) return false;
|
|
381
|
-
const triggerFields = value[keys[0]];
|
|
382
|
-
return !Array.isArray(triggerFields) && typeof triggerFields === 'object';
|
|
383
|
-
},
|
|
384
|
-
{ message: 'Must be object with single key pointing to object' },
|
|
385
|
-
)
|
|
386
|
-
.superRefine((value, ctx) => {
|
|
387
|
-
const triggerName = Object.keys(value)[0];
|
|
388
|
-
const triggerFields = value[triggerName];
|
|
389
|
-
|
|
390
|
-
// Check required fields
|
|
391
|
-
if (!triggerFields.input || typeof triggerFields.input !== 'string') {
|
|
392
|
-
ctx.addIssue({
|
|
393
|
-
code: 'custom',
|
|
394
|
-
message: `Trigger "${triggerName}" must have an input field`,
|
|
395
|
-
});
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (!triggerFields.route && !triggerFields.action) {
|
|
399
|
-
ctx.addIssue({
|
|
400
|
-
code: 'custom',
|
|
401
|
-
message: `Trigger "${triggerName}" must have a route or action field`,
|
|
402
|
-
});
|
|
403
|
-
}
|
|
404
|
-
}),
|
|
405
|
-
]),
|
|
406
|
-
)
|
|
407
|
-
.min(1, { message: 'agent.menu[].triggers must have at least one trigger' }),
|
|
408
|
-
discussion: z.boolean().optional(),
|
|
409
|
-
})
|
|
410
|
-
.strict()
|
|
411
|
-
.superRefine((value, ctx) => {
|
|
412
|
-
// Check for duplicate trigger names
|
|
413
|
-
const seenTriggers = new Set();
|
|
414
|
-
for (const [index, triggerItem] of value.triggers.entries()) {
|
|
415
|
-
let triggerName = null;
|
|
416
|
-
|
|
417
|
-
// Extract trigger name from either format
|
|
418
|
-
if (triggerItem.trigger) {
|
|
419
|
-
// Format 1
|
|
420
|
-
triggerName = triggerItem.trigger;
|
|
421
|
-
} else {
|
|
422
|
-
// Format 2
|
|
423
|
-
const keys = Object.keys(triggerItem);
|
|
424
|
-
if (keys.length === 1) {
|
|
425
|
-
triggerName = keys[0];
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
if (triggerName) {
|
|
430
|
-
if (seenTriggers.has(triggerName)) {
|
|
431
|
-
ctx.addIssue({
|
|
432
|
-
code: 'custom',
|
|
433
|
-
path: ['agent', 'menu', 'triggers', index],
|
|
434
|
-
message: `Trigger name "${triggerName}" is duplicated`,
|
|
435
|
-
});
|
|
436
|
-
}
|
|
437
|
-
seenTriggers.add(triggerName);
|
|
438
|
-
|
|
439
|
-
// Validate trigger name format
|
|
440
|
-
if (!TRIGGER_PATTERN.test(triggerName)) {
|
|
441
|
-
ctx.addIssue({
|
|
442
|
-
code: 'custom',
|
|
443
|
-
path: ['agent', 'menu', 'triggers', index],
|
|
444
|
-
message: `Trigger name "${triggerName}" must be kebab-case (lowercase words separated by hyphen)`,
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
return z.union([legacyMenuItemSchema, multiMenuItemSchema]);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* Derive the expected module slug from a file path residing under src/<module>/agents/.
|
|
456
|
-
* @param {string} filePath Absolute or relative agent path.
|
|
457
|
-
* @returns {string|null} Module slug if identifiable, otherwise null.
|
|
458
|
-
*/
|
|
459
|
-
function deriveModuleFromPath(filePath) {
|
|
460
|
-
assert(filePath, 'validateAgentFile expects filePath to be provided');
|
|
461
|
-
assert(typeof filePath === 'string', 'validateAgentFile expects filePath to be a string');
|
|
462
|
-
assert(filePath.startsWith('src/'), 'validateAgentFile expects filePath to start with "src/"');
|
|
463
|
-
|
|
464
|
-
const marker = 'src/';
|
|
465
|
-
if (!filePath.startsWith(marker)) {
|
|
466
|
-
return null;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
const remainder = filePath.slice(marker.length);
|
|
470
|
-
const slashIndex = remainder.indexOf('/');
|
|
471
|
-
return slashIndex === -1 ? null : remainder.slice(0, slashIndex);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
function normalizeModuleOption(moduleOption) {
|
|
475
|
-
if (typeof moduleOption !== 'string') {
|
|
476
|
-
return null;
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
const trimmed = moduleOption.trim();
|
|
480
|
-
return trimmed.length > 0 ? trimmed : null;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
// Primitive validators -----------------------------------------------------
|
|
484
|
-
|
|
485
|
-
function createNonEmptyString(label) {
|
|
486
|
-
return z.string().refine((value) => value.trim().length > 0, {
|
|
487
|
-
message: `${label} must be a non-empty string`,
|
|
488
|
-
});
|
|
489
|
-
}
|