elsabro 7.3.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.
@@ -54,6 +54,9 @@ class CallbackProtocol {
54
54
  requiresTeam(branches) {
55
55
  if (!Array.isArray(branches) || branches.length < 2) return false;
56
56
 
57
+ // Validate each branch before accessing config
58
+ this._validateBranches(branches, 'requiresTeam');
59
+
57
60
  const allHaiku = branches.every(b =>
58
61
  b.config && b.config.model === 'haiku'
59
62
  );
@@ -90,6 +93,9 @@ class CallbackProtocol {
90
93
  * @returns {{ name: string, agent: string, role: string, model: string }[]}
91
94
  */
92
95
  composeTeam(branches, nodeContext = 'default') {
96
+ // Validate branches before processing
97
+ this._validateBranches(branches, 'composeTeam');
98
+
93
99
  // 1. Start with branch agents
94
100
  const members = branches.map((b, i) => ({
95
101
  name: `${b.id}-${i + 1}`,
@@ -127,6 +133,9 @@ class CallbackProtocol {
127
133
  * @returns {object}
128
134
  */
129
135
  buildParallelInstruction(nodeId, branches, context = {}) {
136
+ // Validate branches before processing
137
+ this._validateBranches(branches, 'buildParallelInstruction');
138
+
130
139
  const useAgentTeams = this.requiresTeam(branches);
131
140
 
132
141
  const instruction = {
@@ -251,6 +260,76 @@ class CallbackProtocol {
251
260
 
252
261
  // ---------- Internal ----------
253
262
 
263
+ /**
264
+ * Validate a single branch object structure.
265
+ * @private
266
+ * @param {*} branch - Branch object to validate
267
+ * @param {number} index - Index in branches array (for error messages)
268
+ * @param {string} methodName - Name of calling method (for error messages)
269
+ * @throws {Error} If branch is invalid
270
+ */
271
+ _validateBranch(branch, index, methodName) {
272
+ if (!branch || typeof branch !== 'object') {
273
+ throw new Error(
274
+ `[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
275
+ `expected object, got ${typeof branch}`
276
+ );
277
+ }
278
+
279
+ if (!branch.id || typeof branch.id !== 'string') {
280
+ throw new Error(
281
+ `[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
282
+ `missing or invalid "id" property (expected non-empty string)`
283
+ );
284
+ }
285
+
286
+ if (!branch.agent || typeof branch.agent !== 'string') {
287
+ throw new Error(
288
+ `[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
289
+ `missing or invalid "agent" property (expected non-empty string)`
290
+ );
291
+ }
292
+
293
+ // Validate config if present (optional but must be object if provided)
294
+ if (branch.config !== undefined && branch.config !== null) {
295
+ if (typeof branch.config !== 'object') {
296
+ throw new Error(
297
+ `[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
298
+ `"config" must be an object, got ${typeof branch.config}`
299
+ );
300
+ }
301
+
302
+ // Validate model if present in config
303
+ if (branch.config.model !== undefined &&
304
+ typeof branch.config.model !== 'string') {
305
+ throw new Error(
306
+ `[CallbackProtocol.${methodName}] Invalid branch at index ${index}: ` +
307
+ `"config.model" must be a string, got ${typeof branch.config.model}`
308
+ );
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Validate branches array structure.
315
+ * @private
316
+ * @param {*} branches - Branches array to validate
317
+ * @param {string} methodName - Name of calling method (for error messages)
318
+ * @throws {Error} If branches array or any branch is invalid
319
+ */
320
+ _validateBranches(branches, methodName) {
321
+ if (!Array.isArray(branches)) {
322
+ throw new Error(
323
+ `[CallbackProtocol.${methodName}] Invalid branches parameter: ` +
324
+ `expected array, got ${typeof branches}`
325
+ );
326
+ }
327
+
328
+ branches.forEach((branch, index) => {
329
+ this._validateBranch(branch, index, methodName);
330
+ });
331
+ }
332
+
254
333
  /**
255
334
  * Get ordered list of support agents for padding, excluding those already in the team.
256
335
  * @private
@@ -107,6 +107,47 @@ class CheckpointManager {
107
107
 
108
108
  return toRemove.length;
109
109
  }
110
+
111
+ /**
112
+ * Auto-cleanup checkpoints older than a specified number of days.
113
+ *
114
+ * @param {string} [flowId] – optional flow identifier (if not provided, cleans all flows)
115
+ * @param {number} [daysOld=7] – remove checkpoints older than this many days
116
+ * @returns {number} number of checkpoints removed
117
+ */
118
+ autoCleanup(flowId = null, daysOld = 7) {
119
+ if (!fs.existsSync(this.dir)) return 0;
120
+
121
+ const now = Date.now();
122
+ const maxAge = daysOld * 24 * 60 * 60 * 1000; // convert days to milliseconds
123
+ const cutoffTimestamp = now - maxAge;
124
+
125
+ let files = fs.readdirSync(this.dir).filter(f => f.endsWith('.json'));
126
+
127
+ // If flowId specified, filter to only that flow
128
+ if (flowId) {
129
+ files = files.filter(f => f.startsWith(`${flowId}-`));
130
+ }
131
+
132
+ let removedCount = 0;
133
+
134
+ for (const filename of files) {
135
+ // Extract timestamp from filename pattern: flowId-timestamp-seq.json
136
+ const match = filename.match(/-(\d+)-\d+\.json$/);
137
+ if (match) {
138
+ const timestamp = parseInt(match[1], 10);
139
+ if (timestamp < cutoffTimestamp) {
140
+ const filepath = path.join(this.dir, filename);
141
+ if (fs.existsSync(filepath)) {
142
+ fs.unlinkSync(filepath);
143
+ removedCount++;
144
+ }
145
+ }
146
+ }
147
+ }
148
+
149
+ return removedCount;
150
+ }
110
151
  }
111
152
 
112
153
  module.exports = { CheckpointManager };
@@ -22,6 +22,152 @@ const { resolveTemplate } = require('./template');
22
22
  const { checkRuntimeStatus, NotImplementedError, DeprecatedNodeError } = require('./executors');
23
23
  const { serializeContext, deserializeContext } = require('./runner');
24
24
 
25
+ // ---------- State and Context Management ----------
26
+
27
+ /**
28
+ * Update state.json with current flow execution state.
29
+ *
30
+ * @param {string} flowId - Flow identifier
31
+ * @param {object} checkpoint - Current checkpoint data
32
+ * @param {string} phase - Current execution phase
33
+ * @param {object} [metadata] - Additional metadata
34
+ */
35
+ function updateStateJson(flowId, checkpoint, phase, metadata = {}) {
36
+ const stateFile = path.join(process.cwd(), '.elsabro', 'state.json');
37
+ let state = {};
38
+
39
+ // Read existing state
40
+ try {
41
+ if (fs.existsSync(stateFile)) {
42
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
43
+ }
44
+ } catch (err) {
45
+ // Initialize new state if file doesn't exist or is corrupted
46
+ state = {
47
+ version: '1.0.0',
48
+ created_at: new Date().toISOString(),
49
+ updated_at: new Date().toISOString(),
50
+ current_flow: null,
51
+ context: {},
52
+ history: [],
53
+ pending_tasks: [],
54
+ blocked_on: null
55
+ };
56
+ }
57
+
58
+ // Update state with current flow info
59
+ state.updated_at = new Date().toISOString();
60
+ state.current_flow = {
61
+ command: 'flow-engine',
62
+ flowId,
63
+ phase,
64
+ currentNode: checkpoint.currentNode,
65
+ nextNode: checkpoint.nextNode,
66
+ nodesVisited: checkpoint.nodesVisited || [],
67
+ started_at: metadata.started_at || state.current_flow?.started_at || new Date().toISOString()
68
+ };
69
+
70
+ // Merge context from checkpoint
71
+ if (checkpoint.context) {
72
+ const ctx = typeof checkpoint.context === 'string'
73
+ ? deserializeContext(checkpoint.context)
74
+ : checkpoint.context;
75
+ state.context = { ...state.context, ...ctx };
76
+ }
77
+
78
+ // Add metadata
79
+ if (metadata.error) {
80
+ state.current_flow.error = metadata.error;
81
+ }
82
+ if (metadata.finished) {
83
+ state.current_flow.finished = true;
84
+ state.current_flow.completed_at = new Date().toISOString();
85
+ }
86
+
87
+ // Ensure directory exists
88
+ const stateDir = path.dirname(stateFile);
89
+ if (!fs.existsSync(stateDir)) {
90
+ fs.mkdirSync(stateDir, { recursive: true });
91
+ }
92
+
93
+ // Write state
94
+ fs.writeFileSync(stateFile, JSON.stringify(state, null, 2));
95
+ }
96
+
97
+ /**
98
+ * Update context.md with human-readable summary.
99
+ *
100
+ * @param {string} flowId - Flow identifier
101
+ * @param {object} checkpoint - Current checkpoint data
102
+ * @param {string} phase - Current execution phase
103
+ * @param {object} [state] - Current state object
104
+ */
105
+ function updateContextMd(flowId, checkpoint, phase, state = null) {
106
+ const contextFile = path.join(process.cwd(), '.elsabro', 'context.md');
107
+
108
+ // Load state if not provided
109
+ if (!state) {
110
+ const stateFile = path.join(process.cwd(), '.elsabro', 'state.json');
111
+ try {
112
+ if (fs.existsSync(stateFile)) {
113
+ state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
114
+ }
115
+ } catch (err) {
116
+ state = { context: {}, history: [] };
117
+ }
118
+ }
119
+
120
+ const now = new Date().toISOString();
121
+ const ctx = state.context || {};
122
+ const history = state.history || [];
123
+
124
+ let content = `# Estado Actual del Proyecto\n\n`;
125
+ content += `**Última actualización**: ${now}\n`;
126
+ content += `**Comando actual**: flow-engine (${flowId})\n`;
127
+ content += `**Fase**: ${phase}\n`;
128
+ content += `**Nodo actual**: ${checkpoint.currentNode || 'none'}\n`;
129
+ content += `**Siguiente nodo**: ${checkpoint.nextNode || 'none'}\n\n`;
130
+
131
+ content += `## Progreso del Flow\n`;
132
+ content += `- **Nodos visitados**: ${(checkpoint.nodesVisited || []).length}\n`;
133
+ content += `- **Nodos recientes**: ${(checkpoint.nodesVisited || []).slice(-5).join(', ') || 'none'}\n\n`;
134
+
135
+ if (ctx.inputs) {
136
+ content += `## Contexto de Entrada\n`;
137
+ content += `- **Task**: ${ctx.inputs.task || 'N/A'}\n`;
138
+ content += `- **Profile**: ${ctx.inputs.profile || 'N/A'}\n`;
139
+ content += `- **Complexity**: ${ctx.inputs.complexity || 'N/A'}\n\n`;
140
+ }
141
+
142
+ if (history.length > 0) {
143
+ content += `## Historial Reciente\n`;
144
+ history.slice(-3).reverse().forEach((entry, idx) => {
145
+ content += `${idx + 1}. ${entry.command} - ${entry.result} (${entry.completed_at || 'N/A'})\n`;
146
+ });
147
+ content += `\n`;
148
+ }
149
+
150
+ content += `## Para Continuar\n`;
151
+ content += `\`\`\`bash\n`;
152
+ if (checkpoint.nextNode) {
153
+ content += `# Flow en progreso - ejecutar siguiente paso\n`;
154
+ content += `node flow-engine/src/cli.js step --flow flows/development-flow.json\n`;
155
+ } else {
156
+ content += `# Flow completado o sin inicializar\n`;
157
+ content += `node flow-engine/src/cli.js status --flow flows/development-flow.json\n`;
158
+ }
159
+ content += `\`\`\`\n`;
160
+
161
+ // Ensure directory exists
162
+ const contextDir = path.dirname(contextFile);
163
+ if (!fs.existsSync(contextDir)) {
164
+ fs.mkdirSync(contextDir, { recursive: true });
165
+ }
166
+
167
+ // Write context
168
+ fs.writeFileSync(contextFile, content);
169
+ }
170
+
25
171
  // ---------- Argument parsing ----------
26
172
 
27
173
  function parseArgs(argv) {
@@ -238,12 +384,20 @@ function cmdInit(args) {
238
384
 
239
385
  const entryNode = engine.graph.entryNode;
240
386
 
241
- cpManager.save(flowId, {
387
+ const checkpointData = {
242
388
  currentNode: null,
243
389
  nextNode: entryNode,
244
390
  context: serializeContext(context),
245
391
  nodesVisited: []
392
+ };
393
+
394
+ cpManager.save(flowId, checkpointData);
395
+
396
+ // Update state.json and context.md
397
+ updateStateJson(flowId, checkpointData, 'initialized', {
398
+ started_at: new Date().toISOString()
246
399
  });
400
+ updateContextMd(flowId, checkpointData, 'initialized');
247
401
 
248
402
  return {
249
403
  initialized: true,
@@ -307,6 +461,17 @@ async function cmdStep(args) {
307
461
  continue;
308
462
  }
309
463
  if (err instanceof NotImplementedError) {
464
+ // Update state with error before returning
465
+ const errorCheckpoint = {
466
+ currentNode: node.id,
467
+ nextNode: currentNodeId,
468
+ context: serializeContext(context),
469
+ nodesVisited: [...nodesVisited]
470
+ };
471
+ updateStateJson(flowId, errorCheckpoint, 'error', {
472
+ error: err.message
473
+ });
474
+ updateContextMd(flowId, errorCheckpoint, 'error');
310
475
  return { error: err.message, stoppedAt: node.id };
311
476
  }
312
477
  }
@@ -327,12 +492,20 @@ async function cmdStep(args) {
327
492
  nodesVisited.push(node.id);
328
493
  context.nodes[node.id] = { outputs: resolvedOutputs };
329
494
 
330
- cpManager.save(flowId, {
495
+ const exitCheckpoint = {
331
496
  currentNode: node.id,
332
497
  nextNode: null,
333
498
  context: serializeContext(context),
334
499
  nodesVisited: [...nodesVisited]
500
+ };
501
+
502
+ cpManager.save(flowId, exitCheckpoint);
503
+
504
+ // Update state.json and context.md for completion
505
+ updateStateJson(flowId, exitCheckpoint, 'done', {
506
+ finished: true
335
507
  });
508
+ updateContextMd(flowId, exitCheckpoint, 'done');
336
509
 
337
510
  return {
338
511
  finished: true,
@@ -430,12 +603,18 @@ async function cmdStep(args) {
430
603
  }
431
604
 
432
605
  // Save checkpoint at this actionable node
433
- cpManager.save(flowId, {
606
+ const actionCheckpoint = {
434
607
  currentNode: node.id,
435
608
  nextNode: currentNodeId,
436
609
  context: serializeContext(context),
437
610
  nodesVisited: [...nodesVisited]
438
- });
611
+ };
612
+
613
+ cpManager.save(flowId, actionCheckpoint);
614
+
615
+ // Update state.json and context.md
616
+ updateStateJson(flowId, actionCheckpoint, 'stepping');
617
+ updateContextMd(flowId, actionCheckpoint, 'stepping');
439
618
 
440
619
  return {
441
620
  instruction,
@@ -446,6 +625,17 @@ async function cmdStep(args) {
446
625
  }
447
626
 
448
627
  // Reached end without actionable node
628
+ const finishedCheckpoint = {
629
+ currentNode: null,
630
+ nextNode: null,
631
+ context: serializeContext(context),
632
+ nodesVisited: [...nodesVisited]
633
+ };
634
+
635
+ // Update state for finished flow
636
+ updateStateJson(flowId, finishedCheckpoint, 'done', { finished: true });
637
+ updateContextMd(flowId, finishedCheckpoint, 'done');
638
+
449
639
  return { finished: true, nodesVisited };
450
640
  }
451
641
 
@@ -514,12 +704,21 @@ function cmdComplete(args) {
514
704
  }
515
705
  }
516
706
 
517
- cpManager.save(flowId, {
707
+ const completedCheckpoint = {
518
708
  currentNode: node.id,
519
709
  nextNode,
520
710
  context: serializeContext(context),
521
711
  nodesVisited: [...nodesVisited]
712
+ };
713
+
714
+ cpManager.save(flowId, completedCheckpoint);
715
+
716
+ // Update state.json and context.md
717
+ const phase = nextNode ? 'stepping' : 'done';
718
+ updateStateJson(flowId, completedCheckpoint, phase, {
719
+ finished: !nextNode
522
720
  });
721
+ updateContextMd(flowId, completedCheckpoint, phase);
523
722
 
524
723
  return {
525
724
  completed: true,
@@ -594,4 +793,13 @@ if (require.main === module) {
594
793
  });
595
794
  }
596
795
 
597
- module.exports = { main, parseArgs, flowIdFromPath, isAutoResolvable, detectNodeContext, COMMANDS };
796
+ module.exports = {
797
+ main,
798
+ parseArgs,
799
+ flowIdFromPath,
800
+ isAutoResolvable,
801
+ detectNodeContext,
802
+ updateStateJson,
803
+ updateContextMd,
804
+ COMMANDS
805
+ };
@@ -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
497
  // Agent Teams are now handled via parallel dispatch in execute.md#3-dispatch + callbacks.js
364
- if (node.runtime_status === 'not_implemented') {
365
- throw new NotImplementedError(node.id, node.gaps);
366
- }
367
498
 
368
499
  // If somehow called on a non-deprecated team node
369
500
  if (callbacks.onTeam) {