voyageai-cli 1.30.0 → 1.30.1

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.
Files changed (55) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +6 -0
  3. package/src/commands/chat.js +32 -11
  4. package/src/commands/export.js +124 -0
  5. package/src/commands/import.js +195 -0
  6. package/src/commands/index-workspace.js +239 -0
  7. package/src/commands/mcp-server.js +113 -3
  8. package/src/commands/playground.js +111 -3
  9. package/src/lib/export/contexts/benchmark-export.js +27 -0
  10. package/src/lib/export/contexts/chat-export.js +41 -0
  11. package/src/lib/export/contexts/explore-export.js +22 -0
  12. package/src/lib/export/contexts/search-export.js +54 -0
  13. package/src/lib/export/contexts/workflow-export.js +80 -0
  14. package/src/lib/export/formats/clipboard-export.js +29 -0
  15. package/src/lib/export/formats/csv-export.js +45 -0
  16. package/src/lib/export/formats/json-export.js +50 -0
  17. package/src/lib/export/formats/markdown-export.js +189 -0
  18. package/src/lib/export/formats/mermaid-export.js +274 -0
  19. package/src/lib/export/formats/pdf-export.js +117 -0
  20. package/src/lib/export/formats/png-export.js +96 -0
  21. package/src/lib/export/formats/svg-export.js +116 -0
  22. package/src/lib/export/index.js +175 -0
  23. package/src/lib/workflow.js +206 -27
  24. package/src/mcp/install.js +280 -7
  25. package/src/mcp/schemas/index.js +40 -0
  26. package/src/mcp/server.js +2 -0
  27. package/src/mcp/tools/workspace.js +463 -0
  28. package/src/playground/announcements.md +52 -5
  29. package/src/playground/index.html +11125 -7796
  30. package/src/playground/vendor/mermaid.min.js +2811 -0
  31. package/src/workflows/rag-chat.json +165 -0
  32. package/src/workflows/tests/consistency-check.happy-path.test.json +28 -0
  33. package/src/workflows/tests/consistency-check.missing-source.test.json +26 -0
  34. package/src/workflows/tests/cost-analysis.happy-path.test.json +28 -0
  35. package/src/workflows/tests/enrich-and-ingest.happy-path.test.json +38 -0
  36. package/src/workflows/tests/enrich-and-ingest.notify-fails.test.json +38 -0
  37. package/src/workflows/tests/intelligent-ingest.all-filtered.test.json +26 -0
  38. package/src/workflows/tests/intelligent-ingest.happy-path.test.json +28 -0
  39. package/src/workflows/tests/kb-health-report.custom-queries.test.json +24 -0
  40. package/src/workflows/tests/kb-health-report.happy-path.test.json +26 -0
  41. package/src/workflows/tests/multi-collection-search.happy-path.test.json +40 -0
  42. package/src/workflows/tests/multi-collection-search.one-empty.test.json +28 -0
  43. package/src/workflows/tests/rag-chat.happy-path.test.json +26 -0
  44. package/src/workflows/tests/rag-chat.no-relevant-results.test.json +25 -0
  45. package/src/workflows/tests/research-and-summarize.happy-path.test.json +33 -0
  46. package/src/workflows/tests/research-and-summarize.no-results.test.json +29 -0
  47. package/src/workflows/tests/search-with-fallback.empty-both.test.json +24 -0
  48. package/src/workflows/tests/search-with-fallback.fallback-branch.test.json +24 -0
  49. package/src/workflows/tests/search-with-fallback.happy-path.test.json +27 -0
  50. package/src/workflows/tests/smart-ingest.duplicate-detected.test.json +34 -0
  51. package/src/workflows/tests/smart-ingest.happy-path.test.json +31 -0
  52. package/src/playground/assets/announcements/appstore.jpg +0 -0
  53. package/src/playground/assets/announcements/circuits.jpg +0 -0
  54. package/src/playground/assets/announcements/csvingest.jpg +0 -0
  55. package/src/playground/assets/announcements/green-wave.jpg +0 -0
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Render an SVG string for export.
5
+ * In CLI context, this generates SVG from Mermaid via Playwright.
6
+ * In Electron/browser context, this serializes and cleans the DOM SVG.
7
+ *
8
+ * @param {object} normalized - Normalized data (must have _context = 'workflow' or provide _svgContent)
9
+ * @param {object} options
10
+ * @param {string} [options.background='transparent'] - 'transparent' | 'dark' | 'light'
11
+ * @param {boolean} [options.includeWatermark=true]
12
+ * @param {boolean} [options.fitToContent=true]
13
+ * @returns {{ content: string, mimeType: string }}
14
+ */
15
+ function renderSvg(normalized, options = {}) {
16
+ // If raw SVG content is provided (from browser/Electron capture), clean and return it
17
+ if (normalized._svgContent) {
18
+ const cleaned = cleanSvg(normalized._svgContent, options);
19
+ return { content: cleaned, mimeType: 'image/svg+xml' };
20
+ }
21
+
22
+ // For CLI: generate SVG from Mermaid (async path handled by renderSvgAsync)
23
+ throw new Error('SVG export requires either _svgContent (browser) or use renderSvgAsync (CLI)');
24
+ }
25
+
26
+ /**
27
+ * Async SVG render — generates SVG from Mermaid using Playwright.
28
+ * @param {object} normalized
29
+ * @param {object} options
30
+ * @returns {Promise<{ content: string, mimeType: string }>}
31
+ */
32
+ async function renderSvgAsync(normalized, options = {}) {
33
+ if (normalized._svgContent) {
34
+ return renderSvg(normalized, options);
35
+ }
36
+
37
+ // Generate Mermaid source, then render via Playwright
38
+ const { workflowToMermaid } = require('./mermaid-export');
39
+ if (normalized._context !== 'workflow' && normalized._context !== 'benchmark') {
40
+ throw new Error(`SVG export not supported for context: ${normalized._context}`);
41
+ }
42
+
43
+ const mermaidSrc = normalized._context === 'workflow'
44
+ ? workflowToMermaid(normalized, options)
45
+ : null;
46
+
47
+ if (!mermaidSrc) {
48
+ throw new Error('No Mermaid source available for SVG rendering');
49
+ }
50
+
51
+ const svg = await renderMermaidToSvg(mermaidSrc, options);
52
+ return { content: svg, mimeType: 'image/svg+xml' };
53
+ }
54
+
55
+ /**
56
+ * Render Mermaid syntax to SVG using Playwright.
57
+ */
58
+ async function renderMermaidToSvg(mermaidSrc, options = {}) {
59
+ const { chromium } = require('playwright');
60
+ const background = options.background || 'transparent';
61
+ const bgColor = background === 'dark' ? '#1e1e1e' : background === 'light' ? '#ffffff' : 'transparent';
62
+
63
+ const browser = await chromium.launch({ headless: true });
64
+ try {
65
+ const page = await browser.newPage();
66
+
67
+ const html = `<!DOCTYPE html>
68
+ <html><head>
69
+ <script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>
70
+ </head><body style="margin:0;background:${bgColor};">
71
+ <pre class="mermaid">${escapeHtml(mermaidSrc)}</pre>
72
+ <script>mermaid.initialize({ startOnLoad: true, theme: '${options.theme || 'dark'}' });</script>
73
+ </body></html>`;
74
+
75
+ await page.setContent(html, { waitUntil: 'networkidle' });
76
+ await page.waitForSelector('svg', { timeout: 10000 });
77
+
78
+ const svg = await page.evaluate(() => {
79
+ const el = document.querySelector('svg');
80
+ // Remove interactive attributes
81
+ el.removeAttribute('style');
82
+ const scripts = el.querySelectorAll('script');
83
+ scripts.forEach(s => s.remove());
84
+ return el.outerHTML;
85
+ });
86
+
87
+ const cleaned = cleanSvg(svg, options);
88
+ return cleaned;
89
+ } finally {
90
+ await browser.close();
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Clean an SVG string: remove scripts, event handlers, optionally add watermark.
96
+ */
97
+ function cleanSvg(svgStr, options = {}) {
98
+ // Strip event handlers
99
+ let svg = svgStr.replace(/\s+on\w+="[^"]*"/g, '');
100
+ // Strip <script> tags
101
+ svg = svg.replace(/<script[\s\S]*?<\/script>/gi, '');
102
+
103
+ if (options.includeWatermark !== false) {
104
+ // Insert watermark before closing </svg>
105
+ const watermark = '<text x="99%" y="99%" text-anchor="end" font-size="10" fill="#666" opacity="0.5">Generated by vai</text>';
106
+ svg = svg.replace('</svg>', `${watermark}</svg>`);
107
+ }
108
+
109
+ return svg;
110
+ }
111
+
112
+ function escapeHtml(str) {
113
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
114
+ }
115
+
116
+ module.exports = { renderSvg, renderSvgAsync, renderMermaidToSvg, cleanSvg };
@@ -0,0 +1,175 @@
1
+ 'use strict';
2
+
3
+ const { renderJson, renderJsonl } = require('./formats/json-export');
4
+ const { renderCsv } = require('./formats/csv-export');
5
+ const { renderMarkdown } = require('./formats/markdown-export');
6
+ const { renderMermaid } = require('./formats/mermaid-export');
7
+ const { copyToClipboard } = require('./formats/clipboard-export');
8
+ const { renderSvgAsync } = require('./formats/svg-export');
9
+ const { renderPng } = require('./formats/png-export');
10
+ const { renderPdf } = require('./formats/pdf-export');
11
+
12
+ const { normalizeWorkflow, WORKFLOW_FORMATS } = require('./contexts/workflow-export');
13
+ const { normalizeSearch, SEARCH_FORMATS } = require('./contexts/search-export');
14
+ const { normalizeChat, CHAT_FORMATS } = require('./contexts/chat-export');
15
+ const { normalizeBenchmark, BENCHMARK_FORMATS } = require('./contexts/benchmark-export');
16
+ const { normalizeExplore, EXPLORE_FORMATS } = require('./contexts/explore-export');
17
+
18
+ // ════════════════════════════════════════════════════════════════════
19
+ // Context → Formats mapping
20
+ // ════════════════════════════════════════════════════════════════════
21
+
22
+ const FORMAT_MAP = {
23
+ workflow: WORKFLOW_FORMATS,
24
+ search: SEARCH_FORMATS,
25
+ chat: CHAT_FORMATS,
26
+ benchmark: BENCHMARK_FORMATS,
27
+ explore: EXPLORE_FORMATS,
28
+ };
29
+
30
+ const TRANSFORMERS = {
31
+ workflow: normalizeWorkflow,
32
+ search: normalizeSearch,
33
+ chat: normalizeChat,
34
+ benchmark: normalizeBenchmark,
35
+ explore: normalizeExplore,
36
+ };
37
+
38
+ const RENDERERS = {
39
+ json: renderJson,
40
+ jsonl: renderJsonl,
41
+ csv: renderCsv,
42
+ markdown: renderMarkdown,
43
+ mermaid: renderMermaid,
44
+ svg: renderSvgAsync,
45
+ png: renderPng,
46
+ pdf: renderPdf,
47
+ };
48
+
49
+ // Formats that produce binary (Buffer) output
50
+ const BINARY_FORMATS = new Set(['png', 'pdf']);
51
+
52
+ const EXT_MAP = {
53
+ json: '.json',
54
+ jsonl: '.jsonl',
55
+ csv: '.csv',
56
+ markdown: '.md',
57
+ mermaid: '.mmd',
58
+ svg: '.svg',
59
+ png: '.png',
60
+ pdf: '.pdf',
61
+ };
62
+
63
+ // ════════════════════════════════════════════════════════════════════
64
+ // Public API
65
+ // ════════════════════════════════════════════════════════════════════
66
+
67
+ /**
68
+ * Get supported formats for a given context.
69
+ * @param {string} context
70
+ * @returns {string[]}
71
+ */
72
+ function getFormatsForContext(context) {
73
+ return FORMAT_MAP[context] || [];
74
+ }
75
+
76
+ /**
77
+ * Build a deterministic filename for the export.
78
+ * Pattern: {context}-{name}-{timestamp}.{ext}
79
+ * @param {string} context
80
+ * @param {object} data
81
+ * @param {string} format
82
+ * @returns {string}
83
+ */
84
+ function buildFilename(context, data, format) {
85
+ const name = (data.name || data.sessionId || data.query || context)
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9]+/g, '-')
88
+ .replace(/^-|-$/g, '')
89
+ .slice(0, 40);
90
+ const ts = new Date().toISOString().replace(/[:.]/g, '').slice(0, 15);
91
+ const ext = EXT_MAP[format] || '.txt';
92
+ return `${context}-${name}-${ts}${ext}`;
93
+ }
94
+
95
+ /**
96
+ * Main export entry point.
97
+ *
98
+ * @param {object} params
99
+ * @param {string} params.context - 'workflow' | 'search' | 'chat' | 'benchmark' | 'explore'
100
+ * @param {string} params.format - 'json' | 'jsonl' | 'csv' | 'markdown' | 'mermaid' | 'clipboard'
101
+ * @param {object} params.data - Raw source data
102
+ * @param {object} [params.options] - Format & context specific options
103
+ * @returns {{ content: string, mimeType: string, suggestedFilename: string, format: string }}
104
+ */
105
+ async function exportArtifact({ context, format, data, options = {} }) {
106
+ // Handle clipboard as a meta-format: pick the best underlying format and copy
107
+ const isClipboard = format === 'clipboard';
108
+ const effectiveFormat = isClipboard ? pickClipboardFormat(context) : format;
109
+
110
+ // Validate
111
+ const supported = getFormatsForContext(context);
112
+ if (!supported.includes(format)) {
113
+ throw new ExportError(
114
+ `Format "${format}" not supported for ${context}. Supported: ${supported.join(', ')}`
115
+ );
116
+ }
117
+
118
+ // Transform
119
+ const transformer = TRANSFORMERS[context];
120
+ if (!transformer) {
121
+ throw new ExportError(`Unknown export context: "${context}"`);
122
+ }
123
+ const normalized = transformer(data, options);
124
+
125
+ // Render
126
+ const renderer = RENDERERS[effectiveFormat];
127
+ if (!renderer) {
128
+ throw new ExportError(`No renderer for format: "${effectiveFormat}"`);
129
+ }
130
+ const output = await renderer(normalized, options);
131
+ const isBinary = BINARY_FORMATS.has(effectiveFormat);
132
+
133
+ // Clipboard side-effect (text only — binary clipboard handled by Electron)
134
+ if (isClipboard) {
135
+ const ok = copyToClipboard(typeof output.content === 'string' ? output.content : output.content.toString());
136
+ if (!ok) {
137
+ throw new ExportError('Failed to copy to clipboard — unsupported platform or missing clipboard tool');
138
+ }
139
+ }
140
+
141
+ return {
142
+ content: output.content,
143
+ mimeType: output.mimeType,
144
+ suggestedFilename: buildFilename(context, data, effectiveFormat),
145
+ format: effectiveFormat,
146
+ };
147
+ }
148
+
149
+ /**
150
+ * Pick the best text format for clipboard based on context.
151
+ */
152
+ function pickClipboardFormat(context) {
153
+ switch (context) {
154
+ case 'workflow': return 'mermaid';
155
+ case 'search': return 'json';
156
+ case 'chat': return 'markdown';
157
+ case 'benchmark': return 'json';
158
+ case 'explore': return 'json';
159
+ default: return 'json';
160
+ }
161
+ }
162
+
163
+ class ExportError extends Error {
164
+ constructor(message) {
165
+ super(message);
166
+ this.name = 'ExportError';
167
+ }
168
+ }
169
+
170
+ module.exports = {
171
+ exportArtifact,
172
+ getFormatsForContext,
173
+ buildFilename,
174
+ ExportError,
175
+ };
@@ -45,32 +45,55 @@ const SCHEMA_LIMITS = {
45
45
 
46
46
  /**
47
47
  * Validate a workflow definition object.
48
- * Returns an array of error strings (empty = valid).
48
+ * Two modes: strict (default) returns error strings for backward compatibility,
49
+ * draft mode returns structured issue objects with severity levels.
49
50
  *
50
51
  * @param {object} definition - Parsed workflow JSON
51
- * @returns {string[]} errors
52
+ * @param {object} [options]
53
+ * @param {'strict'|'draft'} [options.mode='strict'] - Validation mode
54
+ * @returns {string[]|object} In strict: string[] errors. In draft: { valid, mode, issues, stats }
52
55
  */
53
- function validateWorkflow(definition) {
54
- const errors = [];
56
+ function validateWorkflow(definition, { mode = 'strict' } = {}) {
57
+ const issues = [];
58
+
59
+ // Helper to add issue
60
+ function addIssue(severity, stepId, code, message, field = null, referencedStep = null) {
61
+ issues.push({
62
+ severity,
63
+ stepId,
64
+ code,
65
+ message,
66
+ ...(field && { field }),
67
+ ...(referencedStep && { referencedStep })
68
+ });
69
+ }
55
70
 
56
71
  // Top-level required fields
57
72
  if (!definition || typeof definition !== 'object') {
58
- return ['Workflow definition must be a JSON object'];
73
+ addIssue('error', null, 'INVALID_DEFINITION', 'Workflow definition must be a JSON object');
74
+ return mode === 'strict' ? ['Workflow definition must be a JSON object'] : { valid: false, mode, issues, stats: { totalSteps: 0, errors: 1, warnings: 0, info: 0 } };
59
75
  }
76
+
60
77
  if (!definition.name || typeof definition.name !== 'string') {
61
- errors.push('Workflow must have a "name" string');
78
+ const severity = mode === 'draft' ? 'info' : 'error';
79
+ addIssue(severity, null, 'MISSING_WORKFLOW_NAME', 'Workflow must have a "name" string');
62
80
  }
81
+
63
82
  if (!Array.isArray(definition.steps) || definition.steps.length === 0) {
64
- errors.push('Workflow must have a non-empty "steps" array');
83
+ addIssue('error', null, 'MISSING_STEPS', 'Workflow must have a non-empty "steps" array');
65
84
  }
66
85
 
67
- if (errors.length > 0) return errors; // Can't validate steps without them
86
+ const structuralErrors = issues.filter(i => i.severity === 'error');
87
+ if (structuralErrors.length > 0 && !Array.isArray(definition.steps)) {
88
+ // Can't validate steps without them
89
+ return formatResponse(mode, issues, 0);
90
+ }
68
91
 
69
92
  // Validate inputs schema
70
93
  if (definition.inputs) {
71
94
  for (const [key, schema] of Object.entries(definition.inputs)) {
72
95
  if (schema.type && !['string', 'number', 'boolean', 'array'].includes(schema.type)) {
73
- errors.push(`Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
96
+ addIssue('error', null, 'INVALID_INPUT_TYPE', `Input "${key}" has invalid type "${schema.type}" (must be string, number, boolean, or array)`);
74
97
  }
75
98
  }
76
99
  }
@@ -84,7 +107,7 @@ function validateWorkflow(definition) {
84
107
  const prefix = `Step ${i}`;
85
108
 
86
109
  if (!step.id || typeof step.id !== 'string') {
87
- errors.push(`${prefix}: must have a string "id"`);
110
+ addIssue('error', step.id || `step${i}`, 'MISSING_STEP_ID', `${prefix}: must have a string "id"`);
88
111
  continue;
89
112
  }
90
113
 
@@ -98,14 +121,20 @@ function validateWorkflow(definition) {
98
121
 
99
122
  // Tool validation
100
123
  if (!step.tool || typeof step.tool !== 'string') {
101
- errors.push(`${stepPrefix}: must have a string "tool"`);
124
+ addIssue('error', step.id, 'MISSING_TOOL', `${stepPrefix}: must have a string "tool"`);
102
125
  } else if (!ALL_TOOLS.has(step.tool)) {
103
- errors.push(`${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
126
+ addIssue('error', step.id, 'INVALID_TOOL', `${stepPrefix}: unknown tool "${step.tool}" (available: ${[...ALL_TOOLS].join(', ')})`);
104
127
  }
105
128
 
106
129
  // Inputs validation
107
130
  if (step.tool !== 'generate' && (!step.inputs || typeof step.inputs !== 'object')) {
108
- errors.push(`${stepPrefix}: must have an "inputs" object`);
131
+ const severity = mode === 'draft' ? 'info' : 'error';
132
+ addIssue(severity, step.id, 'MISSING_INPUTS', `${stepPrefix}: must have an "inputs" object`);
133
+ }
134
+
135
+ // Step name validation (only in draft mode for better UX)
136
+ if (!step.name && mode === 'draft') {
137
+ addIssue('info', step.id, 'MISSING_STEP_NAME', `${stepPrefix}: missing "name" field`);
109
138
  }
110
139
 
111
140
  // Check template references point to known step IDs or reserved prefixes
@@ -127,25 +156,32 @@ function validateWorkflow(definition) {
127
156
  const deps = extractDependencies(inputsToCheck);
128
157
  for (const dep of deps) {
129
158
  if (!forEachVars.has(dep) && !loopVars.has(dep) && !stepIds.has(dep) && !definition.steps.some(s => s.id === dep)) {
130
- errors.push(`${stepPrefix}: references unknown step "${dep}"`);
159
+ const severity = mode === 'draft' ? 'warning' : 'error';
160
+ addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `${stepPrefix}: references unknown step "${dep}"`, null, dep);
131
161
  }
132
162
  }
133
163
  }
134
164
 
135
165
  // Condition validation (if present, should be a string)
136
166
  if (step.condition !== undefined && typeof step.condition !== 'string') {
137
- errors.push(`${stepPrefix}: "condition" must be a string`);
167
+ addIssue('error', step.id, 'INVALID_CONDITION', `${stepPrefix}: "condition" must be a string`);
168
+ }
169
+
170
+ // Empty condition validation
171
+ if (step.condition !== undefined && typeof step.condition === 'string' && step.condition.trim() === '') {
172
+ const severity = mode === 'draft' ? 'info' : 'error';
173
+ addIssue(severity, step.id, 'EMPTY_CONDITION', `${stepPrefix}: "condition" is empty`);
138
174
  }
139
175
 
140
176
  // forEach validation (if present, should be a template string)
141
177
  if (step.forEach !== undefined && typeof step.forEach !== 'string') {
142
- errors.push(`${stepPrefix}: "forEach" must be a string`);
178
+ addIssue('error', step.id, 'INVALID_FOREACH', `${stepPrefix}: "forEach" must be a string`);
143
179
  }
144
180
  }
145
181
 
146
182
  // Report duplicates
147
183
  for (const id of duplicateIds) {
148
- errors.push(`Duplicate step id: "${id}"`);
184
+ addIssue('error', id, 'DUPLICATE_ID', `Duplicate step id: "${id}"`);
149
185
  }
150
186
 
151
187
  // Validate conditional branch references
@@ -157,40 +193,183 @@ function validateWorkflow(definition) {
157
193
  if (refs && Array.isArray(refs)) {
158
194
  for (const ref of refs) {
159
195
  if (!stepIds.has(ref)) {
160
- errors.push(`Step "${step.id}": conditional ${branch} references unknown step "${ref}"`);
196
+ const severity = mode === 'draft' ? 'warning' : 'error';
197
+ addIssue(severity, step.id, 'UNKNOWN_STEP_REF', `Step "${step.id}": conditional ${branch} references unknown step "${ref}"`, null, ref);
161
198
  }
162
199
  }
163
200
  }
164
201
  }
165
202
  if (!step.inputs.condition) {
166
- errors.push(`Step "${step.id}": conditional must have a "condition" input`);
203
+ const severity = mode === 'draft' ? 'info' : 'error';
204
+ addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "condition" input`, 'inputs.condition');
167
205
  }
168
206
  if (!step.inputs.then || !Array.isArray(step.inputs.then)) {
169
- errors.push(`Step "${step.id}": conditional must have a "then" array`);
207
+ const severity = mode === 'draft' ? 'info' : 'error';
208
+ addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": conditional must have a "then" array`, 'inputs.then');
170
209
  }
171
210
  }
172
211
 
173
212
  // Validate loop inline step
174
213
  if (step.tool === 'loop' && step.inputs) {
175
214
  if (!step.inputs.items) {
176
- errors.push(`Step "${step.id}": loop must have an "items" input`);
215
+ const severity = mode === 'draft' ? 'info' : 'error';
216
+ addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have an "items" input`, 'inputs.items');
177
217
  }
178
218
  if (!step.inputs.as || typeof step.inputs.as !== 'string') {
179
- errors.push(`Step "${step.id}": loop must have a string "as" input`);
219
+ const severity = mode === 'draft' ? 'info' : 'error';
220
+ addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a string "as" input`, 'inputs.as');
180
221
  }
181
222
  if (!step.inputs.step || typeof step.inputs.step !== 'object') {
182
- errors.push(`Step "${step.id}": loop must have a "step" object`);
223
+ const severity = mode === 'draft' ? 'info' : 'error';
224
+ addIssue(severity, step.id, 'MISSING_REQUIRED_INPUT', `Step "${step.id}": loop must have a "step" object`, 'inputs.step');
183
225
  } else if (step.inputs.step.tool && !ALL_TOOLS.has(step.inputs.step.tool)) {
184
- errors.push(`Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
226
+ addIssue('error', step.id, 'INVALID_TOOL', `Step "${step.id}": loop sub-step has unknown tool "${step.inputs.step.tool}"`);
185
227
  }
186
228
  }
187
229
  }
188
230
 
189
231
  // Check for circular dependencies
190
- const cycleErrors = detectCycles(definition.steps);
191
- errors.push(...cycleErrors);
232
+ const cycleResult = detectCyclesAsIssues(definition.steps);
233
+ for (const issue of cycleResult) {
234
+ addIssue('error', issue.stepId, 'CIRCULAR_DEPENDENCY', issue.message);
235
+ }
236
+
237
+ // Draft-only checks: orphan nodes
238
+ if (mode === 'draft') {
239
+ for (const step of definition.steps || []) {
240
+ const isReferenced = (definition.steps || []).some(
241
+ other => other.id !== step.id &&
242
+ extractStepReferences(other).some(ref => ref.stepId === step.id)
243
+ );
244
+ const hasReferences = extractStepReferences(step).length > 0;
245
+ if (!isReferenced && !hasReferences && (definition.steps || []).length > 1) {
246
+ addIssue('info', step.id, 'ORPHAN_NODE', `Step "${step.id}" is not connected to any other step`);
247
+ }
248
+ }
249
+ }
192
250
 
193
- return errors;
251
+ return formatResponse(mode, issues, (definition.steps || []).length);
252
+ }
253
+
254
+ /**
255
+ * Extract step references from a step for orphan detection.
256
+ * @param {object} step
257
+ * @returns {Array<{stepId: string, field?: string}>}
258
+ */
259
+ function extractStepReferences(step) {
260
+ const refs = [];
261
+ if (!step.inputs) return refs;
262
+
263
+ const deps = extractDependencies(step.inputs);
264
+ for (const dep of deps) {
265
+ if (dep !== 'inputs' && dep !== 'defaults' && dep !== 'item' && dep !== 'index') {
266
+ refs.push({ stepId: dep });
267
+ }
268
+ }
269
+
270
+ return refs;
271
+ }
272
+
273
+ /**
274
+ * Format the response based on validation mode.
275
+ * @param {string} mode
276
+ * @param {Array} issues
277
+ * @param {number} totalSteps
278
+ * @returns {string[]|object}
279
+ */
280
+ function formatResponse(mode, issues, totalSteps) {
281
+ const errors = issues.filter(i => i.severity === 'error');
282
+ const warnings = issues.filter(i => i.severity === 'warning');
283
+ const info = issues.filter(i => i.severity === 'info');
284
+
285
+ if (mode === 'draft') {
286
+ return {
287
+ valid: errors.length === 0,
288
+ mode: 'draft',
289
+ issues,
290
+ stats: {
291
+ totalSteps,
292
+ errors: errors.length,
293
+ warnings: warnings.length,
294
+ info: info.length
295
+ }
296
+ };
297
+ }
298
+
299
+ // Strict mode: backward-compatible format
300
+ if (errors.length > 0) {
301
+ return errors.map(e => e.message);
302
+ }
303
+
304
+ // In strict mode, warnings and info are also treated as errors
305
+ const allIssues = issues.filter(i => i.severity !== 'info' || mode === 'strict');
306
+ if (allIssues.length > 0) {
307
+ return allIssues.map(e => e.message);
308
+ }
309
+
310
+ return [];
311
+ }
312
+
313
+ /**
314
+ * Detect circular dependencies and return as issue objects.
315
+ * @param {Array} steps
316
+ * @returns {Array<{stepId: string, message: string}>}
317
+ */
318
+ function detectCyclesAsIssues(steps) {
319
+ const issues = [];
320
+ const stepMap = new Map(steps.map(s => [s.id, s]));
321
+ const adjList = new Map();
322
+
323
+ // Build adjacency list from template dependencies
324
+ for (const step of steps) {
325
+ const deps = extractDependencies(step.inputs || {});
326
+ if (step.condition) {
327
+ const condDeps = extractDependencies(step.condition);
328
+ for (const d of condDeps) deps.add(d);
329
+ }
330
+ if (step.forEach) {
331
+ const forDeps = extractDependencies(step.forEach);
332
+ for (const d of forDeps) deps.add(d);
333
+ }
334
+ adjList.set(step.id, deps);
335
+ }
336
+
337
+ // DFS cycle detection
338
+ const WHITE = 0, GRAY = 1, BLACK = 2;
339
+ const color = new Map(steps.map(s => [s.id, WHITE]));
340
+
341
+ function dfs(nodeId, path) {
342
+ color.set(nodeId, GRAY);
343
+ path.push(nodeId);
344
+
345
+ const neighbors = adjList.get(nodeId) || new Set();
346
+ for (const dep of neighbors) {
347
+ if (!stepMap.has(dep)) continue; // Unknown deps caught by other checks
348
+ if (color.get(dep) === GRAY) {
349
+ const cycleStart = path.indexOf(dep);
350
+ const cycle = path.slice(cycleStart).concat(dep);
351
+ issues.push({
352
+ stepId: cycle[0],
353
+ message: `Circular dependency: ${cycle.join(' -> ')}`
354
+ });
355
+ return;
356
+ }
357
+ if (color.get(dep) === WHITE) {
358
+ dfs(dep, path);
359
+ }
360
+ }
361
+
362
+ path.pop();
363
+ color.set(nodeId, BLACK);
364
+ }
365
+
366
+ for (const step of steps) {
367
+ if (color.get(step.id) === WHITE) {
368
+ dfs(step.id, []);
369
+ }
370
+ }
371
+
372
+ return issues;
194
373
  }
195
374
 
196
375
  /**