elsabro 6.0.0 → 7.0.0

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.
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * CLI entry point for the ELSABRO flow engine.
6
+ *
7
+ * Commands:
8
+ * validate --flow <path> Validate flow, report parallel nodes + team compositions
9
+ * dry-run --flow <path> --task <str> --profile <str> Full run with mock callbacks
10
+ * init --flow <path> --task <str> --profile <str> Create initial checkpoint
11
+ * step --flow <path> Advance to next actionable node
12
+ * complete --flow <path> --result <json> Apply result, advance checkpoint
13
+ * status --flow <path> Show current state
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { FlowEngine } = require('./index');
19
+ const { CheckpointManager } = require('./checkpoint');
20
+ const { CallbackProtocol } = require('./callbacks');
21
+ const { resolveTemplate } = require('./template');
22
+ const { checkRuntimeStatus, NotImplementedError, DeprecatedNodeError } = require('./executors');
23
+ const { serializeContext, deserializeContext } = require('./runner');
24
+
25
+ // ---------- Argument parsing ----------
26
+
27
+ function parseArgs(argv) {
28
+ const args = {};
29
+ let command = null;
30
+
31
+ for (let i = 2; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+ if (!arg.startsWith('--') && !command) {
34
+ command = arg;
35
+ } else if (arg.startsWith('--')) {
36
+ const key = arg.slice(2);
37
+ const next = argv[i + 1];
38
+ if (next && !next.startsWith('--')) {
39
+ args[key] = next;
40
+ i++;
41
+ } else {
42
+ args[key] = true;
43
+ }
44
+ }
45
+ }
46
+
47
+ return { command, args };
48
+ }
49
+
50
+ // ---------- Flow ID from path ----------
51
+
52
+ function flowIdFromPath(flowPath) {
53
+ const basename = path.basename(flowPath, '.json');
54
+ return basename.toLowerCase().replace(/[^a-z0-9-]/g, '-');
55
+ }
56
+
57
+ // ---------- Node classification ----------
58
+
59
+ const AUTO_RESOLVABLE = new Set(['entry', 'condition', 'router', 'exit']);
60
+
61
+ function isAutoResolvable(node) {
62
+ if (AUTO_RESOLVABLE.has(node.type)) return true;
63
+ // Deprecated team nodes are auto-skipped
64
+ if (node.type === 'team' && node.runtime_status === 'deprecated') return true;
65
+ return false;
66
+ }
67
+
68
+ // ---------- Detect node context for team padding ----------
69
+
70
+ function detectNodeContext(nodeId) {
71
+ if (nodeId.includes('implementation') || nodeId.includes('implement')) return 'implementation';
72
+ if (nodeId.includes('review') || nodeId.includes('check')) return 'review';
73
+ if (nodeId.includes('fix') || nodeId.includes('debug') || nodeId.includes('error')) return 'fix';
74
+ return 'default';
75
+ }
76
+
77
+ // ---------- Commands ----------
78
+
79
+ /**
80
+ * validate: Parse and validate flow, report parallel nodes + team compositions.
81
+ */
82
+ function cmdValidate(args) {
83
+ const flowPath = args.flow;
84
+ if (!flowPath) return { error: 'Missing --flow <path>' };
85
+
86
+ const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
87
+ const engine = new FlowEngine();
88
+ engine.loadFlow(flowJson);
89
+
90
+ const protocol = new CallbackProtocol();
91
+ const parallelNodes = engine.getNodesWhere(n => n.type === 'parallel');
92
+
93
+ const teamAnalysis = parallelNodes.map(node => {
94
+ const branches = node.branches || [];
95
+ const needsTeam = protocol.requiresTeam(branches);
96
+ const nodeContext = detectNodeContext(node.id);
97
+
98
+ let team = null;
99
+ if (needsTeam) {
100
+ const members = protocol.composeTeam(branches, nodeContext);
101
+ team = {
102
+ name: protocol.generateTeamName(node.id),
103
+ memberCount: members.length,
104
+ members: members.map(m => ({ name: m.name, agent: m.agent, role: m.role }))
105
+ };
106
+ }
107
+
108
+ return {
109
+ nodeId: node.id,
110
+ branchCount: branches.length,
111
+ useAgentTeams: needsTeam,
112
+ reason: needsTeam ? `${branches.length} non-haiku branches` : 'all-haiku (subagents)',
113
+ team
114
+ };
115
+ });
116
+
117
+ return {
118
+ valid: true,
119
+ flowId: engine.getFlowMetadata().id,
120
+ nodeCount: engine.getNodeCount(),
121
+ parallelNodes: teamAnalysis
122
+ };
123
+ }
124
+
125
+ /**
126
+ * dry-run: Run full flow with mock callbacks, report path + team actions.
127
+ */
128
+ async function cmdDryRun(args) {
129
+ const flowPath = args.flow;
130
+ if (!flowPath) return { error: 'Missing --flow <path>' };
131
+
132
+ const task = args.task || 'dry-run task';
133
+ const profile = args.profile || 'default';
134
+ const complexity = args.complexity || 'medium';
135
+
136
+ const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
137
+ const engine = new FlowEngine();
138
+ engine.loadFlow(flowJson);
139
+
140
+ const protocol = new CallbackProtocol();
141
+ const teamActions = [];
142
+
143
+ const callbacks = {
144
+ onAgent: async (params) => {
145
+ // Return passed:true for verifier agents so verify_check conditions pass
146
+ if (params.agent === 'elsabro-verifier' || (params.id && params.id.includes('verify'))) {
147
+ return { passed: true, result: 'mock' };
148
+ }
149
+ return { result: 'mock', agent: params.agent };
150
+ },
151
+ onParallel: async (params) => {
152
+ const instruction = protocol.buildParallelInstruction(
153
+ params.nodeId,
154
+ params.branches,
155
+ { nodeContext: detectNodeContext(params.nodeId), taskSlug: task }
156
+ );
157
+ if (instruction.useAgentTeams) {
158
+ teamActions.push({
159
+ nodeId: params.nodeId,
160
+ teamName: instruction.team.name,
161
+ memberCount: instruction.team.members.length,
162
+ members: instruction.team.members.map(m => m.agent)
163
+ });
164
+ }
165
+ return params.branches.map(b => ({ id: b.id, result: 'mock' }));
166
+ },
167
+ onInterrupt: async (params) => {
168
+ const routes = params.routes || {};
169
+ const options = (params.display && params.display.options) || [];
170
+ // Auto-select: prefer 'approve' or first option
171
+ const approveOpt = options.find(o => o.id === 'approve' || o.id === 'continue');
172
+ if (approveOpt) return approveOpt.id;
173
+ const keys = Object.keys(routes);
174
+ return keys.length > 0 ? keys[0] : null;
175
+ },
176
+ onBash: async (cmd) => ({ output: 'mock', exitCode: 0 }),
177
+ onReadFiles: async (files) => ({ content: 'mock' }),
178
+ onNodeComplete: async (nodeId, result, ctx) => {
179
+ // For sequence nodes without explicit outputs: copy step data into node
180
+ // outputs so downstream conditions (e.g. quality_check) can reference them.
181
+ if (ctx) {
182
+ const node = engine.graph.nodes.get(nodeId);
183
+ if (node && node.type === 'sequence' && !node.outputs) {
184
+ const stepOutputs = {};
185
+ for (const step of (node.steps || [])) {
186
+ if (step.as && ctx.steps[step.as]) {
187
+ stepOutputs[step.as] = ctx.steps[step.as].output || {};
188
+ }
189
+ }
190
+ if (Object.keys(stepOutputs).length > 0) {
191
+ ctx.nodes[nodeId] = ctx.nodes[nodeId] || {};
192
+ ctx.nodes[nodeId].outputs = stepOutputs;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ };
198
+
199
+ const result = await engine.run(
200
+ { task, profile, complexity },
201
+ callbacks
202
+ );
203
+
204
+ return {
205
+ success: result.success,
206
+ nodesVisited: result.nodesVisited,
207
+ nodeCount: result.nodesVisited.length,
208
+ teamActions,
209
+ log: protocol.getLog()
210
+ };
211
+ }
212
+
213
+ /**
214
+ * init: Create initial checkpoint for a flow.
215
+ */
216
+ function cmdInit(args) {
217
+ const flowPath = args.flow;
218
+ if (!flowPath) return { error: 'Missing --flow <path>' };
219
+
220
+ const task = args.task || '';
221
+ const profile = args.profile || 'default';
222
+ const complexity = args.complexity || 'medium';
223
+
224
+ const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
225
+ const engine = new FlowEngine();
226
+ engine.loadFlow(flowJson);
227
+
228
+ const flowId = flowIdFromPath(flowPath);
229
+ const cpManager = new CheckpointManager();
230
+
231
+ const context = {
232
+ inputs: { task, profile, complexity },
233
+ nodes: {},
234
+ steps: {},
235
+ state: {},
236
+ _iterations: {}
237
+ };
238
+
239
+ const entryNode = engine.graph.entryNode;
240
+
241
+ cpManager.save(flowId, {
242
+ currentNode: null,
243
+ nextNode: entryNode,
244
+ context: serializeContext(context),
245
+ nodesVisited: []
246
+ });
247
+
248
+ return {
249
+ initialized: true,
250
+ flowId,
251
+ entryNode,
252
+ task,
253
+ profile
254
+ };
255
+ }
256
+
257
+ /**
258
+ * step: Advance through auto-resolvable nodes, stop at actionable node.
259
+ */
260
+ async function cmdStep(args) {
261
+ const flowPath = args.flow;
262
+ if (!flowPath) return { error: 'Missing --flow <path>' };
263
+
264
+ const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
265
+ const engine = new FlowEngine();
266
+ engine.loadFlow(flowJson);
267
+
268
+ const flowId = flowIdFromPath(flowPath);
269
+ const cpManager = new CheckpointManager();
270
+ const checkpoint = cpManager.load(flowId);
271
+
272
+ if (!checkpoint) {
273
+ return { error: 'No checkpoint found. Run init first.' };
274
+ }
275
+
276
+ const protocol = new CallbackProtocol();
277
+ const context = deserializeContext(checkpoint.context);
278
+ const nodesVisited = checkpoint.nodesVisited || [];
279
+ let currentNodeId = checkpoint.nextNode;
280
+
281
+ if (currentNodeId === null) {
282
+ return { finished: true, nodesVisited };
283
+ }
284
+
285
+ const maxSteps = 50;
286
+ let stepCount = 0;
287
+
288
+ while (currentNodeId !== null) {
289
+ stepCount++;
290
+ if (stepCount > maxSteps) {
291
+ return { error: 'Exceeded max auto-resolve steps (50). Possible loop.' };
292
+ }
293
+
294
+ const node = engine.graph.nodes.get(currentNodeId);
295
+ if (!node) {
296
+ return { error: `Node "${currentNodeId}" not found in graph` };
297
+ }
298
+
299
+ // Check for deprecated/not-implemented
300
+ try {
301
+ checkRuntimeStatus(node);
302
+ } catch (err) {
303
+ if (err instanceof DeprecatedNodeError) {
304
+ // Skip deprecated nodes
305
+ nodesVisited.push(node.id);
306
+ currentNodeId = node.next || null;
307
+ continue;
308
+ }
309
+ if (err instanceof NotImplementedError) {
310
+ return { error: err.message, stoppedAt: node.id };
311
+ }
312
+ }
313
+
314
+ // If auto-resolvable, execute inline
315
+ if (isAutoResolvable(node)) {
316
+ let result;
317
+
318
+ switch (node.type) {
319
+ case 'entry':
320
+ result = { next: node.next, outputs: {} };
321
+ break;
322
+
323
+ case 'exit': {
324
+ const resolvedOutputs = node.outputs
325
+ ? resolveTemplate(node.outputs, context)
326
+ : {};
327
+ nodesVisited.push(node.id);
328
+ context.nodes[node.id] = { outputs: resolvedOutputs };
329
+
330
+ cpManager.save(flowId, {
331
+ currentNode: node.id,
332
+ nextNode: null,
333
+ context: serializeContext(context),
334
+ nodesVisited: [...nodesVisited]
335
+ });
336
+
337
+ return {
338
+ finished: true,
339
+ exitNode: node.id,
340
+ status: node.status || 'completed',
341
+ nodesVisited
342
+ };
343
+ }
344
+
345
+ case 'condition': {
346
+ const conditionStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
347
+ ? node.condition
348
+ : `{{${node.condition}}}`;
349
+ const condResult = resolveTemplate(conditionStr, context);
350
+ result = { next: condResult ? node.true : node.false, outputs: { conditionResult: !!condResult } };
351
+ break;
352
+ }
353
+
354
+ case 'router': {
355
+ const condStr = typeof node.condition === 'string' && node.condition.startsWith('{{')
356
+ ? node.condition
357
+ : `{{${node.condition}}}`;
358
+ const routeKey = String(resolveTemplate(condStr, context));
359
+ const routes = node.routes || {};
360
+ let nextNode = routes[routeKey];
361
+ if (!nextNode && node.default) {
362
+ nextNode = typeof node.default === 'string' && routes[node.default]
363
+ ? routes[node.default]
364
+ : node.default;
365
+ }
366
+ result = { next: nextNode || null, outputs: { routeKey } };
367
+ break;
368
+ }
369
+
370
+ case 'team':
371
+ // Deprecated team nodes — skip
372
+ result = { next: node.next || null, outputs: {} };
373
+ break;
374
+
375
+ default:
376
+ result = { next: node.next || null, outputs: {} };
377
+ }
378
+
379
+ // Store and advance
380
+ nodesVisited.push(node.id);
381
+ context.nodes[node.id] = { outputs: result.outputs || {} };
382
+ currentNodeId = result.next;
383
+ continue;
384
+ }
385
+
386
+ // Actionable node — build instruction and stop
387
+ let instruction;
388
+ const nodeContext = detectNodeContext(node.id);
389
+
390
+ switch (node.type) {
391
+ case 'agent': {
392
+ const resolvedInputs = node.inputs
393
+ ? resolveTemplate(node.inputs, context)
394
+ : {};
395
+ instruction = protocol.buildAgentInstruction(
396
+ node.id, node.agent, node.config, resolvedInputs
397
+ );
398
+ break;
399
+ }
400
+
401
+ case 'parallel': {
402
+ const resolvedBranches = (node.branches || []).map(b => ({
403
+ ...b,
404
+ inputs: b.inputs ? resolveTemplate(b.inputs, context) : {}
405
+ }));
406
+ instruction = protocol.buildParallelInstruction(
407
+ node.id, resolvedBranches,
408
+ { nodeContext, taskSlug: context.inputs && context.inputs.task }
409
+ );
410
+ break;
411
+ }
412
+
413
+ case 'interrupt': {
414
+ const resolvedDisplay = node.display
415
+ ? resolveTemplate(node.display, context)
416
+ : { title: node.reason || 'Interrupt', options: [] };
417
+ instruction = protocol.buildInterruptInstruction(
418
+ node.id, resolvedDisplay, node.routes, node.reason
419
+ );
420
+ break;
421
+ }
422
+
423
+ case 'sequence': {
424
+ instruction = protocol.buildSequenceInstruction(node.id, node.steps);
425
+ break;
426
+ }
427
+
428
+ default:
429
+ instruction = { type: node.type, nodeId: node.id };
430
+ }
431
+
432
+ // Save checkpoint at this actionable node
433
+ cpManager.save(flowId, {
434
+ currentNode: node.id,
435
+ nextNode: currentNodeId,
436
+ context: serializeContext(context),
437
+ nodesVisited: [...nodesVisited]
438
+ });
439
+
440
+ return {
441
+ instruction,
442
+ nodeId: node.id,
443
+ nodeType: node.type,
444
+ description: node.description || ''
445
+ };
446
+ }
447
+
448
+ // Reached end without actionable node
449
+ return { finished: true, nodesVisited };
450
+ }
451
+
452
+ /**
453
+ * complete: Apply result to current node, advance checkpoint.
454
+ */
455
+ function cmdComplete(args) {
456
+ const flowPath = args.flow;
457
+ if (!flowPath) return { error: 'Missing --flow <path>' };
458
+
459
+ const resultStr = args.result;
460
+ if (!resultStr) return { error: 'Missing --result <json>' };
461
+
462
+ let result;
463
+ try {
464
+ result = JSON.parse(resultStr);
465
+ } catch {
466
+ return { error: 'Invalid JSON in --result' };
467
+ }
468
+
469
+ const flowJson = JSON.parse(fs.readFileSync(flowPath, 'utf8'));
470
+ const engine = new FlowEngine();
471
+ engine.loadFlow(flowJson);
472
+
473
+ const flowId = flowIdFromPath(flowPath);
474
+ const cpManager = new CheckpointManager();
475
+ const checkpoint = cpManager.load(flowId);
476
+
477
+ if (!checkpoint) {
478
+ return { error: 'No checkpoint found. Run init first.' };
479
+ }
480
+
481
+ const context = deserializeContext(checkpoint.context);
482
+ const nodesVisited = checkpoint.nodesVisited || [];
483
+ const currentNodeId = checkpoint.nextNode || checkpoint.currentNode;
484
+
485
+ if (!currentNodeId) {
486
+ return { error: 'No current node to complete.' };
487
+ }
488
+
489
+ // Guard against double-completion: after cmdStep, nextNode === currentNode.
490
+ // After cmdComplete, nextNode advances past currentNode. If we see them
491
+ // differ, this checkpoint was already completed — need a step first.
492
+ if (checkpoint.nextNode && checkpoint.nextNode !== checkpoint.currentNode) {
493
+ return { error: `Node "${checkpoint.currentNode}" was already completed. Run step first.` };
494
+ }
495
+
496
+ const node = engine.graph.nodes.get(currentNodeId);
497
+ if (!node) {
498
+ return { error: `Node "${currentNodeId}" not found` };
499
+ }
500
+
501
+ // Store result
502
+ nodesVisited.push(node.id);
503
+ context.nodes[node.id] = { outputs: result };
504
+
505
+ // Determine next node
506
+ let nextNode = node.next || null;
507
+
508
+ // Handle maxIterations for parallel nodes
509
+ if (node.type === 'parallel' && node.maxIterations) {
510
+ context._iterations = context._iterations || {};
511
+ context._iterations[node.id] = (context._iterations[node.id] || 0) + 1;
512
+ if (context._iterations[node.id] >= node.maxIterations && node.onMaxIterations) {
513
+ nextNode = node.onMaxIterations;
514
+ }
515
+ }
516
+
517
+ cpManager.save(flowId, {
518
+ currentNode: node.id,
519
+ nextNode,
520
+ context: serializeContext(context),
521
+ nodesVisited: [...nodesVisited]
522
+ });
523
+
524
+ return {
525
+ completed: true,
526
+ nodeId: node.id,
527
+ nextNode
528
+ };
529
+ }
530
+
531
+ /**
532
+ * status: Show current state of an active session.
533
+ */
534
+ function cmdStatus(args) {
535
+ const flowPath = args.flow;
536
+ if (!flowPath) return { error: 'Missing --flow <path>' };
537
+
538
+ const flowId = flowIdFromPath(flowPath);
539
+ const cpManager = new CheckpointManager();
540
+ const checkpoint = cpManager.load(flowId);
541
+
542
+ if (!checkpoint) {
543
+ return { active: false, message: 'No active session.' };
544
+ }
545
+
546
+ return {
547
+ active: true,
548
+ flowId: checkpoint.flowId || flowId,
549
+ currentNode: checkpoint.currentNode,
550
+ nextNode: checkpoint.nextNode,
551
+ nodesVisited: checkpoint.nodesVisited || [],
552
+ visitedCount: (checkpoint.nodesVisited || []).length,
553
+ createdAt: checkpoint.createdAt || null
554
+ };
555
+ }
556
+
557
+ // ---------- Main dispatcher ----------
558
+
559
+ const COMMANDS = {
560
+ validate: cmdValidate,
561
+ 'dry-run': cmdDryRun,
562
+ init: cmdInit,
563
+ step: cmdStep,
564
+ complete: cmdComplete,
565
+ status: cmdStatus
566
+ };
567
+
568
+ async function main(argv) {
569
+ const { command, args } = parseArgs(argv || process.argv);
570
+
571
+ if (!command || command === 'help') {
572
+ return {
573
+ usage: 'elsabro-flow <command> [options]',
574
+ commands: Object.keys(COMMANDS)
575
+ };
576
+ }
577
+
578
+ const handler = COMMANDS[command];
579
+ if (!handler) {
580
+ return { error: `Unknown command: "${command}". Available: ${Object.keys(COMMANDS).join(', ')}` };
581
+ }
582
+
583
+ return handler(args);
584
+ }
585
+
586
+ // Run if invoked directly
587
+ if (require.main === module) {
588
+ main().then(result => {
589
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
590
+ if (result.error) process.exitCode = 1;
591
+ }).catch(err => {
592
+ process.stdout.write(JSON.stringify({ error: err.message }, null, 2) + '\n');
593
+ process.exitCode = 1;
594
+ });
595
+ }
596
+
597
+ module.exports = { main, parseArgs, flowIdFromPath, isAutoResolvable, detectNodeContext, COMMANDS };
@@ -33,12 +33,24 @@ class ExecutorError extends Error {
33
33
  }
34
34
  }
35
35
 
36
+ class DeprecatedNodeError extends Error {
37
+ constructor(nodeId, reason) {
38
+ super(`Node "${nodeId}" is deprecated: ${reason}`);
39
+ this.name = 'DeprecatedNodeError';
40
+ this.nodeId = nodeId;
41
+ this.reason = reason;
42
+ }
43
+ }
44
+
36
45
  // ---------- Runtime status guard ----------
37
46
 
38
47
  function checkRuntimeStatus(node) {
39
48
  if (node.runtime_status === 'not_implemented') {
40
49
  throw new NotImplementedError(node.id, node.gaps);
41
50
  }
51
+ if (node.runtime_status === 'deprecated') {
52
+ throw new DeprecatedNodeError(node.id, node.deprecated_reason || 'Node is deprecated');
53
+ }
42
54
  }
43
55
 
44
56
  // ---------- Simple Executors ----------
@@ -402,5 +414,6 @@ module.exports = {
402
414
  getExecutor,
403
415
  checkRuntimeStatus,
404
416
  NotImplementedError,
405
- ExecutorError
417
+ ExecutorError,
418
+ DeprecatedNodeError
406
419
  };
@@ -15,7 +15,9 @@ const { buildGraph, validateGraph, getNode, GraphError } = require('./graph');
15
15
  const { resolveTemplate, resolveExpression, TemplateError } = require('./template');
16
16
  const { runFlow, serializeContext, deserializeContext } = require('./runner');
17
17
  const { CheckpointManager } = require('./checkpoint');
18
- const { NotImplementedError } = require('./executors');
18
+ const { NotImplementedError, DeprecatedNodeError } = require('./executors');
19
+ const { PartyEngine } = require('./party');
20
+ const { CallbackProtocol } = require('./callbacks');
19
21
 
20
22
  class FlowEngine {
21
23
  /**
@@ -134,4 +136,4 @@ class FlowEngine {
134
136
  }
135
137
  }
136
138
 
137
- module.exports = { FlowEngine, GraphError, TemplateError, NotImplementedError, CheckpointManager };
139
+ module.exports = { FlowEngine, GraphError, TemplateError, NotImplementedError, DeprecatedNodeError, CheckpointManager, PartyEngine, CallbackProtocol };