elsabro 7.2.0 → 7.3.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.
@@ -9,9 +9,15 @@
9
9
  * 9 node types total:
10
10
  * Simple (Wave 2): entry, exit, condition, router, sequence
11
11
  * Complex (Wave 3): agent, parallel, interrupt, team
12
+ *
13
+ * NOTE: The elsabro-orchestrator agent uses Agent Teams (TeamCreate, TeamDelete, SendMessage)
14
+ * for parallel execution coordination. This is NOT related to the elsabro-scrum-master agent,
15
+ * which is a separate utility for sprint planning. They are independent agents serving
16
+ * different purposes.
12
17
  */
13
18
 
14
19
  const { resolveTemplate, resolveExpression } = require('./template');
20
+ const { serializeContext } = require('./runner');
15
21
 
16
22
  // ---------- Error types ----------
17
23
 
@@ -59,6 +65,7 @@ function checkRuntimeStatus(node) {
59
65
  * Entry node: no-op, just transitions to next.
60
66
  */
61
67
  async function executeEntry(node, context, callbacks) {
68
+ checkRuntimeStatus(node);
62
69
  return { next: node.next, outputs: {} };
63
70
  }
64
71
 
@@ -66,6 +73,7 @@ async function executeEntry(node, context, callbacks) {
66
73
  * Exit node: resolve output templates, terminate flow.
67
74
  */
68
75
  async function executeExit(node, context, callbacks) {
76
+ checkRuntimeStatus(node);
69
77
  const resolvedOutputs = node.outputs
70
78
  ? resolveTemplate(node.outputs, context)
71
79
  : {};
@@ -81,7 +89,18 @@ async function executeExit(node, context, callbacks) {
81
89
  * Condition node: evaluate condition, route to true/false branch.
82
90
  */
83
91
  async function executeCondition(node, context, callbacks) {
92
+ checkRuntimeStatus(node);
84
93
  if (!node.condition) {
94
+ // Save checkpoint before error
95
+ if (callbacks.onCheckpoint) {
96
+ await callbacks.onCheckpoint({
97
+ currentNode: node.id,
98
+ nextNode: null,
99
+ context: serializeContext(context),
100
+ stoppedAt: node.id,
101
+ reason: 'Condition node has no "condition" field'
102
+ });
103
+ }
85
104
  throw new ExecutorError(node.id, 'Condition node has no "condition" field');
86
105
  }
87
106
 
@@ -94,6 +113,16 @@ async function executeCondition(node, context, callbacks) {
94
113
  const nextNode = result ? node.true : node.false;
95
114
 
96
115
  if (!nextNode) {
116
+ // Save checkpoint before error
117
+ if (callbacks.onCheckpoint) {
118
+ await callbacks.onCheckpoint({
119
+ currentNode: node.id,
120
+ nextNode: null,
121
+ context: serializeContext(context),
122
+ stoppedAt: node.id,
123
+ reason: `Condition evaluated to ${!!result} but no "${result ? 'true' : 'false'}" branch defined`
124
+ });
125
+ }
97
126
  throw new ExecutorError(
98
127
  node.id,
99
128
  `Condition evaluated to ${!!result} but no "${result ? 'true' : 'false'}" branch defined`
@@ -107,6 +136,7 @@ async function executeCondition(node, context, callbacks) {
107
136
  * Router node: evaluate condition as a string, match against routes map.
108
137
  */
109
138
  async function executeRouter(node, context, callbacks) {
139
+ checkRuntimeStatus(node);
110
140
  if (!node.condition) {
111
141
  throw new ExecutorError(node.id, 'Router node has no "condition" field');
112
142
  }
@@ -139,8 +169,19 @@ async function executeRouter(node, context, callbacks) {
139
169
 
140
170
  /**
141
171
  * Sequence node: execute steps sequentially, collect step outputs.
172
+ *
173
+ * Special integrations:
174
+ * - skill-discovery.sh: Auto-parses JSON output, extracts discovered/recommended skills
175
+ * - skill-install.sh: Can be called via bash action for skill installation
176
+ *
177
+ * Callbacks used:
178
+ * - onBash(command): Execute bash commands
179
+ * - onReadFiles(files): Read file contents
180
+ * - onAgent(params): Invoke agent execution
181
+ * - onLog(level, message): Optional logging callback for skill discovery results
142
182
  */
143
183
  async function executeSequence(node, context, callbacks) {
184
+ checkRuntimeStatus(node);
144
185
  const steps = node.steps || [];
145
186
 
146
187
  for (const step of steps) {
@@ -154,6 +195,39 @@ async function executeSequence(node, context, callbacks) {
154
195
  const command = resolveTemplate(step.command, context);
155
196
  if (callbacks.onBash) {
156
197
  stepOutput = await callbacks.onBash(command);
198
+
199
+ // Special handling for skill-discovery hook integration
200
+ if (command.includes('skill-discovery.sh') && stepOutput.output) {
201
+ try {
202
+ // Parse JSON output from skill-discovery
203
+ const discoveryData = JSON.parse(stepOutput.output);
204
+
205
+ // Enhance output with parsed discovery data
206
+ stepOutput.discovered = discoveryData.discovered || [];
207
+ stepOutput.recommended = discoveryData.recommended || {};
208
+ stepOutput.registry_available = discoveryData.registry_available || false;
209
+
210
+ // Log skill discovery results (if callbacks provide logging)
211
+ if (callbacks.onLog) {
212
+ callbacks.onLog('info', `[skill-discovery] Found ${stepOutput.discovered.length} installed skills`);
213
+
214
+ if (stepOutput.recommended.skills && stepOutput.recommended.skills.length > 0) {
215
+ callbacks.onLog('info', `[skill-discovery] Recommended: ${stepOutput.recommended.skills.join(', ')}`);
216
+ }
217
+
218
+ if (stepOutput.recommended.install_commands && stepOutput.recommended.install_commands.length > 0) {
219
+ callbacks.onLog('info', `[skill-discovery] ${stepOutput.recommended.install_commands.length} install commands available`);
220
+ }
221
+ }
222
+ } catch (parseErr) {
223
+ // If JSON parse fails, log warning but don't fail the step
224
+ if (callbacks.onLog) {
225
+ callbacks.onLog('warn', `[skill-discovery] Failed to parse output as JSON: ${parseErr.message}`);
226
+ }
227
+ // Keep raw output available
228
+ stepOutput.parseError = parseErr.message;
229
+ }
230
+ }
157
231
  } else {
158
232
  stepOutput = { output: '', exitCode: -1, skipped: true };
159
233
  }
@@ -234,6 +308,7 @@ async function executeSequence(node, context, callbacks) {
234
308
  * Agent node: invoke an external agent via callback.
235
309
  */
236
310
  async function executeAgent(node, context, callbacks) {
311
+ checkRuntimeStatus(node);
237
312
  const resolvedInputs = node.inputs
238
313
  ? resolveTemplate(node.inputs, context)
239
314
  : {};
@@ -267,6 +342,7 @@ async function executeAgent(node, context, callbacks) {
267
342
  * Parallel node: execute branches concurrently via callback.
268
343
  */
269
344
  async function executeParallel(node, context, callbacks) {
345
+ checkRuntimeStatus(node);
270
346
  const branches = node.branches || [];
271
347
 
272
348
  // Resolve inputs for each branch
@@ -313,9 +389,65 @@ async function executeParallel(node, context, callbacks) {
313
389
  if (node.maxIterations && context._iterations[node.id] >= node.maxIterations) {
314
390
  if (node.onMaxIterations) {
315
391
  return { next: node.onMaxIterations, outputs: { branches: branchOutputs } };
392
+ } else {
393
+ // Fall through to next when no handler defined (graceful degradation)
394
+ return { next: node.next, outputs: { branches: branchOutputs } };
316
395
  }
317
396
  }
318
397
 
398
+ // Check errorPolicy quorum for parallel failures
399
+ if (node.errorPolicy === 'quorum') {
400
+ // Count successful branches
401
+ let successCount = 0;
402
+ const totalCount = branchResults.length;
403
+
404
+ for (const branchResult of branchResults) {
405
+ // Consider a branch successful if it doesn't have an error and wasn't skipped
406
+ if (branchResult && !branchResult.error && !branchResult.skipped) {
407
+ successCount++;
408
+ }
409
+ }
410
+
411
+ // Determine if quorum is met
412
+ let quorumMet = true;
413
+ let quorumType = 'default';
414
+
415
+ if (node.quorumThreshold !== undefined) {
416
+ // Percentage-based quorum (0.0 to 1.0)
417
+ quorumType = 'threshold';
418
+ const successRate = totalCount > 0 ? successCount / totalCount : 0;
419
+ quorumMet = successRate >= node.quorumThreshold;
420
+ } else if (node.quorumMinSuccess !== undefined) {
421
+ // Absolute count quorum
422
+ quorumType = 'minSuccess';
423
+ quorumMet = successCount >= node.quorumMinSuccess;
424
+ } else {
425
+ // Default: all must succeed (100% quorum)
426
+ quorumType = 'all';
427
+ quorumMet = successCount === totalCount;
428
+ }
429
+
430
+ // Build detailed outputs
431
+ const quorumOutputs = {
432
+ branches: branchOutputs,
433
+ quorumMet,
434
+ quorumType,
435
+ successCount,
436
+ totalCount,
437
+ failureCount: totalCount - successCount,
438
+ successRate: totalCount > 0 ? successCount / totalCount : 0
439
+ };
440
+
441
+ if (!quorumMet) {
442
+ // Quorum not met, route to error handler if defined
443
+ const errorRoute = node.onError || node.next;
444
+ return { next: errorRoute, outputs: quorumOutputs };
445
+ }
446
+
447
+ // Quorum met, proceed to next
448
+ return { next: node.next, outputs: quorumOutputs };
449
+ }
450
+
319
451
  return { next: node.next, outputs: { branches: branchOutputs } };
320
452
  }
321
453
 
@@ -323,6 +455,7 @@ async function executeParallel(node, context, callbacks) {
323
455
  * Interrupt node: pause execution, display info, route on user choice.
324
456
  */
325
457
  async function executeInterrupt(node, context, callbacks) {
458
+ checkRuntimeStatus(node);
326
459
  const resolvedDisplay = node.display
327
460
  ? resolveTemplate(node.display, context)
328
461
  : { title: node.reason || 'Interrupt', options: [] };
@@ -359,11 +492,9 @@ async function executeInterrupt(node, context, callbacks) {
359
492
  * Team node: deprecated, throws NotImplementedError.
360
493
  */
361
494
  async function executeTeam(node, context, callbacks) {
495
+ checkRuntimeStatus(node);
362
496
  // Team nodes are deprecated in ELSABRO v5.3+
363
- // Agent Teams are now handled inline via IMPERATIVO_AGENT_TEAMS in execute.md
364
- if (node.runtime_status === 'not_implemented') {
365
- throw new NotImplementedError(node.id, node.gaps);
366
- }
497
+ // Agent Teams are now handled via parallel dispatch in execute.md#3-dispatch + callbacks.js
367
498
 
368
499
  // If somehow called on a non-deprecated team node
369
500
  if (callbacks.onTeam) {
@@ -64,15 +64,16 @@ function buildGraph(flowDefinition) {
64
64
 
65
65
  /**
66
66
  * Validate that all node references (next, routes, true, false, onMaxIterations,
67
- * onError) point to existing nodes.
67
+ * onError) point to existing nodes, and detect orphaned nodes.
68
68
  *
69
- * @param {{ nodes: Map<string, object> }} graph
69
+ * @param {{ nodes: Map<string, object>, entryNode: string }} graph
70
70
  * @returns {{ valid: boolean, errors: string[] }}
71
71
  */
72
72
  function validateGraph(graph) {
73
73
  const errors = [];
74
74
  const nodeIds = new Set(graph.nodes.keys());
75
75
 
76
+ // 1. Check for dangling references (invalid routes)
76
77
  for (const [id, node] of graph.nodes) {
77
78
  const refs = getNextNodes(node);
78
79
  for (const ref of refs) {
@@ -82,6 +83,33 @@ function validateGraph(graph) {
82
83
  }
83
84
  }
84
85
 
86
+ // 2. Detect orphaned nodes (nodes that are never reachable from entry)
87
+ const reachable = new Set();
88
+ const queue = [graph.entryNode];
89
+
90
+ while (queue.length > 0) {
91
+ const currentId = queue.shift();
92
+ if (reachable.has(currentId)) continue;
93
+
94
+ reachable.add(currentId);
95
+ const node = graph.nodes.get(currentId);
96
+ if (!node) continue; // Skip if node doesn't exist (handled by dangling ref check)
97
+
98
+ const nextNodes = getNextNodes(node);
99
+ for (const nextId of nextNodes) {
100
+ if (!reachable.has(nextId) && nodeIds.has(nextId)) {
101
+ queue.push(nextId);
102
+ }
103
+ }
104
+ }
105
+
106
+ // Find orphaned nodes
107
+ for (const nodeId of nodeIds) {
108
+ if (!reachable.has(nodeId)) {
109
+ errors.push(`Orphaned node "${nodeId}" is unreachable from entry point`);
110
+ }
111
+ }
112
+
85
113
  return { valid: errors.length === 0, errors };
86
114
  }
87
115
 
@@ -73,7 +73,17 @@ registerFunction('collectErrors', (context) => {
73
73
  registerFunction('hasCriticalIssues', (context, obj) => {
74
74
  if (!obj) return false;
75
75
  const json = typeof obj === 'string' ? obj : JSON.stringify(obj);
76
- return json.includes('"critical"') || json.includes('"blocking"');
76
+
77
+ // Enhanced pattern detection for critical issues
78
+ const criticalPatterns = [
79
+ '"critical"',
80
+ '"blocking"',
81
+ '"P0"',
82
+ '"MUST_FIX"',
83
+ '"URGENT"'
84
+ ];
85
+
86
+ return criticalPatterns.some(pattern => json.includes(pattern));
77
87
  });
78
88
 
79
89
  registerFunction('generateSummary', (context) => {
@@ -144,92 +154,162 @@ function parseLiteral(str) {
144
154
  }
145
155
 
146
156
  /**
147
- * Resolve a single expression string (the content inside {{ }}).
148
- *
149
- * @param {string} expr – raw expression without {{ }}
150
- * @param {object} context { inputs, nodes, steps, state }
151
- * @returns {*}
157
+ * Handle filter syntax: "value | filterName"
158
+ * @param {string} expr
159
+ * @param {object} context
160
+ * @returns {*|null} - Filtered value or null if no match
152
161
  */
153
- function resolveExpression(expr, context) {
154
- const trimmed = expr.trim();
155
-
156
- // 1. Check for filter syntax: "value | filterName"
157
- // Only match pipes that are NOT inside || (fallback operator)
158
- const filterMatch = trimmed.match(/^(.+?)\s*\|\s*(?!\|)(\w+)\s*$/);
159
- if (filterMatch) {
160
- const innerValue = resolveExpression(filterMatch[1], context);
161
- const filterName = filterMatch[2];
162
- const filter = _filters.get(filterName);
163
- if (!filter) {
164
- throw new TemplateError(`Unknown filter: "${filterName}"`);
165
- }
166
- return filter(innerValue);
162
+ function tryResolveFilter(expr, context) {
163
+ // Only match pipes that are NOT inside || (fallback operator)
164
+ const filterMatch = expr.match(/^(.+?)\s*\|\s*(?!\|)(\w+)\s*$/);
165
+ if (!filterMatch) return null;
166
+
167
+ const innerValue = resolveExpression(filterMatch[1], context);
168
+ const filterName = filterMatch[2];
169
+ const filter = _filters.get(filterName);
170
+ if (!filter) {
171
+ throw new TemplateError(`Unknown filter: "${filterName}"`);
167
172
  }
173
+ return filter(innerValue);
174
+ }
168
175
 
169
- // 2. Check for fallback operator: "expr || default"
170
- const fallbackMatch = trimmed.match(/^(.+?)\s*\|\|\s*(.+)$/);
171
- if (fallbackMatch) {
172
- const primary = resolveExpression(fallbackMatch[1], context);
173
- if (primary != null && primary !== '') return primary;
174
- // Try to resolve the default as an expression or literal
175
- const fallbackLiteral = parseLiteral(fallbackMatch[2]);
176
- if (fallbackLiteral.matched) return fallbackLiteral.value;
177
- return resolveExpression(fallbackMatch[2], context);
178
- }
176
+ /**
177
+ * Handle fallback operator: "expr || default"
178
+ * @param {string} expr
179
+ * @param {object} context
180
+ * @returns {*|null} - Primary or fallback value, or null if no match
181
+ */
182
+ function tryResolveFallback(expr, context) {
183
+ const fallbackMatch = expr.match(/^(.+?)\s*\|\|\s*(.+)$/);
184
+ if (!fallbackMatch) return null;
185
+
186
+ const primary = resolveExpression(fallbackMatch[1], context);
187
+ if (primary != null && primary !== '') return primary;
188
+
189
+ // Try to resolve the default as a literal or expression
190
+ const fallbackLiteral = parseLiteral(fallbackMatch[2]);
191
+ if (fallbackLiteral.matched) return fallbackLiteral.value;
192
+ return resolveExpression(fallbackMatch[2], context);
193
+ }
194
+
195
+ /**
196
+ * Handle negation: "!expr"
197
+ * @param {string} expr
198
+ * @param {object} context
199
+ * @returns {*|null} - Negated value or null if no match
200
+ */
201
+ function tryResolveNegation(expr, context) {
202
+ if (!expr.startsWith('!')) return null;
203
+ const inner = resolveExpression(expr.slice(1), context);
204
+ return !inner;
205
+ }
179
206
 
180
- // 3. Check for negation: "!expr"
181
- if (trimmed.startsWith('!')) {
182
- const inner = resolveExpression(trimmed.slice(1), context);
183
- return !inner;
207
+ /**
208
+ * Handle boolean && expression
209
+ * @param {string} expr
210
+ * @param {object} context
211
+ * @returns {*|null} - Boolean result or null if no match
212
+ */
213
+ function tryResolveAnd(expr, context) {
214
+ if (!expr.includes('&&')) return null;
215
+
216
+ const parts = expr.split('&&').map(p => p.trim());
217
+ return parts.every(part => {
218
+ const val = resolveExpression(part, context);
219
+ return !!val;
220
+ });
221
+ }
222
+
223
+ /**
224
+ * Handle comparison operators: ===, !==
225
+ * @param {string} expr
226
+ * @param {object} context
227
+ * @returns {*|null} - Comparison result or null if no match
228
+ */
229
+ function tryResolveComparison(expr, context) {
230
+ const compMatch = expr.match(/^(.+?)\s*(===|!==)\s*(.+)$/);
231
+ if (!compMatch) return null;
232
+
233
+ const left = resolveExpression(compMatch[1], context);
234
+ const right = resolveExpression(compMatch[3], context);
235
+ if (compMatch[2] === '===') return left === right;
236
+ if (compMatch[2] === '!==') return left !== right;
237
+ return null;
238
+ }
239
+
240
+ /**
241
+ * Handle function call: "functionName(args)"
242
+ * @param {string} expr
243
+ * @param {object} context
244
+ * @returns {*|null} - Function result or null if no match
245
+ */
246
+ function tryResolveFunctionCall(expr, context) {
247
+ const funcMatch = expr.match(/^(\w+)\(([^)]*)\)$/);
248
+ if (!funcMatch) return null;
249
+
250
+ const funcName = funcMatch[1];
251
+ const fn = _functions.get(funcName);
252
+ if (!fn) {
253
+ throw new TemplateError(`Unknown function: "${funcName}"`);
184
254
  }
185
255
 
186
- // 4. Check for boolean && expression
187
- if (trimmed.includes('&&')) {
188
- const parts = trimmed.split('&&').map(p => p.trim());
189
- return parts.every(part => {
190
- const val = resolveExpression(part, context);
191
- return !!val;
192
- });
256
+ const argsStr = funcMatch[2].trim();
257
+ if (argsStr === '') {
258
+ return fn(context);
193
259
  }
194
260
 
195
- // 5. Check for comparison operators: ===, !==
196
- const compMatch = trimmed.match(/^(.+?)\s*(===|!==)\s*(.+)$/);
197
- if (compMatch) {
198
- const left = resolveExpression(compMatch[1], context);
199
- const right = resolveExpression(compMatch[3], context);
200
- if (compMatch[2] === '===') return left === right;
201
- if (compMatch[2] === '!==') return left !== right;
261
+ // Parse argument: could be a path reference or a quoted string
262
+ const argLiteral = parseLiteral(argsStr);
263
+ if (argLiteral.matched) {
264
+ return fn(context, argLiteral.value);
202
265
  }
203
266
 
204
- // 6. Check for function call: "functionName(args)"
205
- const funcMatch = trimmed.match(/^(\w+)\(([^)]*)\)$/);
206
- if (funcMatch) {
207
- const funcName = funcMatch[1];
208
- const fn = _functions.get(funcName);
209
- if (!fn) {
210
- throw new TemplateError(`Unknown function: "${funcName}"`);
211
- }
212
- const argsStr = funcMatch[2].trim();
213
- if (argsStr === '') {
214
- return fn(context);
215
- }
216
- // Parse argument: could be a path reference or a quoted string
217
- const argLiteral = parseLiteral(argsStr);
218
- if (argLiteral.matched) {
219
- return fn(context, argLiteral.value);
220
- }
221
- // Resolve as expression (path)
222
- const argValue = resolveExpression(argsStr, context);
223
- return fn(context, argValue);
267
+ // Resolve as expression (path)
268
+ const argValue = resolveExpression(argsStr, context);
269
+ return fn(context, argValue);
270
+ }
271
+
272
+ /**
273
+ * Resolve a single expression string (the content inside {{ }}).
274
+ *
275
+ * Delegates to specialized handlers in order:
276
+ * 1. Filter syntax: "value | filterName"
277
+ * 2. Fallback operator: "expr || default"
278
+ * 3. Negation: "!expr"
279
+ * 4. Boolean AND: "a && b"
280
+ * 5. Comparison: "a === b", "a !== b"
281
+ * 6. Function call: "functionName(args)"
282
+ * 7. Literal value: true, false, number, string, [], {}
283
+ * 8. Path resolution: "inputs.task", "nodes.X.outputs.Y"
284
+ *
285
+ * @param {string} expr – raw expression without {{ }}
286
+ * @param {object} context – { inputs, nodes, steps, state }
287
+ * @returns {*}
288
+ */
289
+ function resolveExpression(expr, context) {
290
+ const trimmed = expr.trim();
291
+
292
+ // Try each expression type in order (most specific to least specific)
293
+ const handlers = [
294
+ tryResolveFilter,
295
+ tryResolveFallback,
296
+ tryResolveNegation,
297
+ tryResolveAnd,
298
+ tryResolveComparison,
299
+ tryResolveFunctionCall
300
+ ];
301
+
302
+ for (const handler of handlers) {
303
+ const result = handler(trimmed, context);
304
+ if (result !== null) return result;
224
305
  }
225
306
 
226
- // 7. Literal value
307
+ // Try literal value
227
308
  const literal = parseLiteral(trimmed);
228
309
  if (literal.matched) return literal.value;
229
310
 
230
- // 8. Path resolution (the most common case)
231
- const value = resolvePath(context, trimmed);
232
- return value;
311
+ // Default: path resolution (most common case)
312
+ return resolvePath(context, trimmed);
233
313
  }
234
314
 
235
315
  /**