agent-state-machine 1.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,1359 @@
1
+ /**
2
+ * File: /lib/state-machine.js
3
+ */
4
+
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { spawn } from 'child_process';
8
+ import readline from 'readline';
9
+ import { createRequire } from 'module';
10
+
11
+ const require = createRequire(import.meta.url);
12
+
13
+ // State machine states
14
+ const States = {
15
+ IDLE: 'IDLE',
16
+ RUNNING: 'RUNNING',
17
+ STEP_EXECUTING: 'STEP_EXECUTING',
18
+ STEP_COMPLETED: 'STEP_COMPLETED',
19
+ STEP_FAILED: 'STEP_FAILED',
20
+ WORKFLOW_COMPLETED: 'WORKFLOW_COMPLETED',
21
+ WORKFLOW_FAILED: 'WORKFLOW_FAILED',
22
+ PAUSED: 'PAUSED'
23
+ };
24
+
25
+ // Built-in agents
26
+ const BUILTIN_AGENTS = {
27
+ echo: async (context) => {
28
+ console.log('[Agent: echo] Context:', JSON.stringify(context, null, 2));
29
+ return { ...context, echoed: true };
30
+ },
31
+
32
+ transform: async (context) => {
33
+ console.log('[Agent: transform] Processing...');
34
+ return {
35
+ ...context,
36
+ transformed: true,
37
+ transformedAt: new Date().toISOString()
38
+ };
39
+ },
40
+
41
+ validate: async (context) => {
42
+ console.log('[Agent: validate] Validating...');
43
+ const isValid = context !== null && typeof context === 'object';
44
+ return { ...context, validated: isValid };
45
+ },
46
+
47
+ log: async (context) => {
48
+ console.log('[Agent: log]', JSON.stringify(context, null, 2));
49
+ return context;
50
+ },
51
+
52
+ delay: async (context) => {
53
+ const ms = context._delay || 1000;
54
+ console.log(`[Agent: delay] Waiting ${ms}ms...`);
55
+ await new Promise(resolve => setTimeout(resolve, ms));
56
+ return context;
57
+ }
58
+ };
59
+
60
+ class StateMachine {
61
+ constructor(workflowName) {
62
+ this.workflowName = workflowName;
63
+ this.workflowsDir = path.join(process.cwd(), 'workflows');
64
+ this.workflowDir = workflowName
65
+ ? path.join(this.workflowsDir, workflowName)
66
+ : null;
67
+ this.customAgents = {};
68
+ this.loop = {
69
+ count: 0,
70
+ stepCounts: {}
71
+ };
72
+ }
73
+
74
+ get stateDir() {
75
+ return this.workflowDir ? path.join(this.workflowDir, 'state') : null;
76
+ }
77
+
78
+ get interactionsDir() {
79
+ return this.workflowDir ? path.join(this.workflowDir, 'interactions') : null;
80
+ }
81
+
82
+ get currentStateFile() {
83
+ return this.stateDir ? path.join(this.stateDir, 'current.json') : null;
84
+ }
85
+
86
+ get historyFile() {
87
+ return this.stateDir ? path.join(this.stateDir, 'history.jsonl') : null;
88
+ }
89
+
90
+ get workflowFile() {
91
+ return this.workflowDir ? path.join(this.workflowDir, 'workflow.js') : null;
92
+ }
93
+
94
+ get agentsDir() {
95
+ return this.workflowDir ? path.join(this.workflowDir, 'agents') : null;
96
+ }
97
+
98
+ get scriptsDir() {
99
+ return this.workflowDir ? path.join(this.workflowDir, 'scripts') : null;
100
+ }
101
+
102
+ get steeringDir() {
103
+ return this.workflowDir ? path.join(this.workflowDir, 'steering') : null;
104
+ }
105
+
106
+ /**
107
+ * Ensure workflow exists
108
+ */
109
+ ensureWorkflow() {
110
+ if (!this.workflowName) {
111
+ throw new Error('No workflow name specified');
112
+ }
113
+ if (!fs.existsSync(this.workflowDir)) {
114
+ throw new Error(`Workflow '${this.workflowName}' not found. Run 'state-machine --setup ${this.workflowName}' first.`);
115
+ }
116
+ if (!fs.existsSync(this.workflowFile)) {
117
+ throw new Error(`workflow.js not found in ${this.workflowDir}`);
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Load workflow configuration from workflow.js
123
+ */
124
+ loadWorkflowConfig() {
125
+ this.ensureWorkflow();
126
+ // Clear require cache to allow hot reloading
127
+ delete require.cache[require.resolve(this.workflowFile)];
128
+ return require(this.workflowFile);
129
+ }
130
+
131
+ /**
132
+ * Load current state
133
+ */
134
+ loadCurrentState() {
135
+ if (fs.existsSync(this.currentStateFile)) {
136
+ return JSON.parse(fs.readFileSync(this.currentStateFile, 'utf-8'));
137
+ }
138
+ return this.createInitialState();
139
+ }
140
+
141
+ /**
142
+ * Create initial state
143
+ */
144
+ createInitialState() {
145
+ return {
146
+ status: States.IDLE,
147
+ workflow: null,
148
+ currentStepIndex: 0,
149
+ context: {},
150
+ loop: {
151
+ count: 0,
152
+ stepCounts: {}
153
+ },
154
+ startedAt: null,
155
+ lastUpdatedAt: null,
156
+ error: null,
157
+ pendingInteraction: null
158
+ };
159
+ }
160
+
161
+ sanitizeInteractionSlug(slug) {
162
+ const raw = String(slug || '').trim();
163
+ const withoutExt = raw.toLowerCase().endsWith('.md') ? raw.slice(0, -3) : raw;
164
+ const sanitized = withoutExt
165
+ .trim()
166
+ .replace(/[\\/]+/g, '-')
167
+ .replace(/[^a-zA-Z0-9._-]+/g, '-')
168
+ .replace(/-+/g, '-')
169
+ .replace(/^-|-$/g, '');
170
+ return sanitized || 'interaction';
171
+ }
172
+
173
+ ensureInteractionsDir() {
174
+ if (!this.interactionsDir) return;
175
+ if (!fs.existsSync(this.interactionsDir)) {
176
+ fs.mkdirSync(this.interactionsDir, { recursive: true });
177
+ }
178
+ }
179
+
180
+ async promptYesToContinue() {
181
+ if (!process.stdin.isTTY) {
182
+ console.log('stdin is not a TTY; edit the interaction file and run `state-machine resume <workflow>` to continue.');
183
+ return false;
184
+ }
185
+
186
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
187
+ const ask = (q) => new Promise((resolve) => rl.question(q, resolve));
188
+ try {
189
+ while (true) {
190
+ const answer = String(await ask("Type 'y' to continue (or 'q' to stop): ")).trim().toLowerCase();
191
+ if (answer === 'y' || answer === 'yes') return true;
192
+ if (answer === 'q' || answer === 'quit' || answer === 'n' || answer === 'no') return false;
193
+ }
194
+ } finally {
195
+ rl.close();
196
+ }
197
+ }
198
+
199
+ async handleInteractionIfNeeded(context, state, workflowName, stepName, stepIndex) {
200
+ const interaction = context?._interaction;
201
+ if (!interaction) return context;
202
+
203
+ const slug = this.sanitizeInteractionSlug(interaction.slug || interaction.key || interaction.targetKey || stepName);
204
+ const targetKey = String(interaction.targetKey || interaction.key || slug);
205
+ const initialContent = String(interaction.content ?? '');
206
+
207
+ this.ensureInteractionsDir();
208
+ const filePath = path.join(this.interactionsDir, `${slug}.md`);
209
+ const relativePath = path.relative(this.workflowDir, filePath);
210
+ const instructions = `Enter your response here. Its okay to delete all content and leave only your response.`;
211
+ const fileExists = fs.existsSync(filePath);
212
+ const interactionContent = fileExists ? fs.readFileSync(filePath, 'utf-8') : '';
213
+
214
+ if (!fileExists || interactionContent.trim() === '') {
215
+ fs.writeFileSync(filePath, initialContent || `# ${slug}\n\n${instructions}\n`);
216
+ } else {
217
+ fs.writeFileSync(filePath, initialContent + '\n\n' + instructions + '\n\n' + interactionContent);
218
+ }
219
+
220
+ state.status = States.PAUSED;
221
+ state.pendingInteraction = {
222
+ slug,
223
+ targetKey,
224
+ file: relativePath,
225
+ stepIndex,
226
+ step: stepName
227
+ };
228
+ this.saveCurrentState(state);
229
+
230
+ this.prependHistory({
231
+ event: 'INTERACTION_REQUESTED',
232
+ workflow: workflowName,
233
+ step: stepName,
234
+ stepIndex,
235
+ slug,
236
+ file: relativePath,
237
+ targetKey
238
+ });
239
+
240
+ console.log(`\n⏸ Interaction required: ${relativePath}`);
241
+ console.log(`The workflow is paused at step ${stepIndex + 1}.`);
242
+ console.log(`After editing, ${targetKey} will be set to the file contents in context.`);
243
+ const continued = await this.promptYesToContinue();
244
+
245
+ if (!continued) {
246
+ console.log(`Workflow paused. Resume with: state-machine resume ${this.workflowName}`);
247
+ return context;
248
+ }
249
+
250
+ const finalContent = fs.readFileSync(filePath, 'utf-8');
251
+ const nextContext = { ...context, [targetKey]: finalContent };
252
+ delete nextContext._interaction;
253
+
254
+ state.context = nextContext;
255
+ state.status = States.RUNNING;
256
+ state.pendingInteraction = null;
257
+ this.saveCurrentState(state);
258
+
259
+ this.prependHistory({
260
+ event: 'INTERACTION_RESOLVED',
261
+ workflow: workflowName,
262
+ step: stepName,
263
+ stepIndex,
264
+ slug,
265
+ file: relativePath,
266
+ targetKey
267
+ });
268
+
269
+ return nextContext;
270
+ }
271
+
272
+ /**
273
+ * Save current state
274
+ */
275
+ saveCurrentState(state) {
276
+ state.lastUpdatedAt = new Date().toISOString();
277
+ state.loop = this.loop;
278
+ fs.writeFileSync(this.currentStateFile, JSON.stringify(state, null, 2));
279
+ }
280
+
281
+ /**
282
+ * Prepend to history
283
+ */
284
+ prependHistory(entry) {
285
+ const historyEntry = {
286
+ ...entry,
287
+ timestamp: new Date().toISOString()
288
+ };
289
+
290
+ const line = JSON.stringify(historyEntry) + '\n';
291
+
292
+ let existing = '';
293
+ if (fs.existsSync(this.historyFile)) {
294
+ existing = fs.readFileSync(this.historyFile, 'utf8');
295
+ }
296
+
297
+ fs.writeFileSync(this.historyFile, line + existing, 'utf8');
298
+ }
299
+
300
+ /**
301
+ * Load history
302
+ */
303
+ loadHistory() {
304
+ if (!fs.existsSync(this.historyFile)) {
305
+ return [];
306
+ }
307
+ const content = fs.readFileSync(this.historyFile, 'utf-8');
308
+ return content
309
+ .split('\n')
310
+ .filter(line => line.trim())
311
+ .map(line => JSON.parse(line));
312
+ }
313
+
314
+ /**
315
+ * Load custom agents from workflow's agents directory
316
+ * Supports both .js (code) and .md (prompt-based) agents
317
+ */
318
+ loadCustomAgents() {
319
+ if (!fs.existsSync(this.agentsDir)) {
320
+ return;
321
+ }
322
+
323
+ const files = fs.readdirSync(this.agentsDir);
324
+ for (const file of files) {
325
+ if (file.endsWith('.js')) {
326
+ // JavaScript agent
327
+ const agentName = path.basename(file, '.js');
328
+ const agentPath = path.join(this.agentsDir, file);
329
+ try {
330
+ // Clear require cache
331
+ delete require.cache[require.resolve(agentPath)];
332
+ const agentModule = require(agentPath);
333
+ const handler = agentModule.handler || agentModule.default || agentModule;
334
+ if (typeof handler === 'function') {
335
+ this.customAgents[agentName] = handler;
336
+ }
337
+ } catch (err) {
338
+ console.warn(`Warning: Failed to load agent '${agentName}': ${err.message}`);
339
+ }
340
+ } else if (file.endsWith('.md')) {
341
+ // Markdown prompt agent
342
+ const agentName = path.basename(file, '.md');
343
+ const agentPath = path.join(this.agentsDir, file);
344
+ try {
345
+ const promptContent = fs.readFileSync(agentPath, 'utf-8');
346
+ // Parse frontmatter if present
347
+ const { config, prompt } = this.parseMarkdownAgent(promptContent);
348
+ this.customAgents[agentName] = this.createMarkdownAgentHandler(agentName, prompt, config);
349
+ } catch (err) {
350
+ console.warn(`Warning: Failed to load markdown agent '${agentName}': ${err.message}`);
351
+ }
352
+ }
353
+ }
354
+ }
355
+
356
+ /**
357
+ * Parse markdown agent file - supports YAML frontmatter
358
+ */
359
+ parseMarkdownAgent(content) {
360
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
361
+
362
+ if (frontmatterMatch) {
363
+ // Parse simple YAML-like frontmatter
364
+ const frontmatter = frontmatterMatch[1];
365
+ const prompt = frontmatterMatch[2].trim();
366
+
367
+ const config = {};
368
+ frontmatter.split('\n').forEach(line => {
369
+ const [key, ...valueParts] = line.split(':');
370
+ if (key && valueParts.length) {
371
+ const value = valueParts.join(':').trim();
372
+ // Remove quotes if present
373
+ config[key.trim()] = value.replace(/^["']|["']$/g, '');
374
+ }
375
+ });
376
+
377
+ return { config, prompt };
378
+ }
379
+
380
+ return { config: {}, prompt: content.trim() };
381
+ }
382
+
383
+ /**
384
+ * Create a handler function for markdown-based agents
385
+ */
386
+ createMarkdownAgentHandler(agentName, promptTemplate, config) {
387
+ const self = this;
388
+
389
+ return async function markdownAgentHandler(context) {
390
+ const { llm } = require('./llm');
391
+
392
+ const getByPath = (obj, pathStr) => {
393
+ const trimmed = String(pathStr || '').trim();
394
+ if (!trimmed) return undefined;
395
+ const normalized = trimmed.startsWith('context.') ? trimmed.slice('context.'.length) : trimmed;
396
+ const parts = normalized.split('.').filter(Boolean);
397
+ let current = obj;
398
+ for (const part of parts) {
399
+ if (current == null) return undefined;
400
+ current = current[String(part)];
401
+ }
402
+ return current;
403
+ };
404
+
405
+ // Interpolate context variables in prompt using {{path}} syntax (supports dots and hyphens).
406
+ const prompt = promptTemplate.replace(/\{\{\s*([^}]+?)\s*\}\}/g, (match, pathStr) => {
407
+ const value = getByPath(context, pathStr);
408
+ return value !== undefined ? String(value) : match;
409
+ });
410
+
411
+ const model = config.model || 'fast';
412
+ const outputKey = config.output || 'result';
413
+
414
+ console.log(` [MD Agent: ${agentName}] Using model: ${model}`);
415
+
416
+ const response = await llm(context, {
417
+ model: model,
418
+ prompt: prompt,
419
+ includeContext: config.includeContext !== 'false'
420
+ });
421
+
422
+ // Parse output based on config
423
+ let output = response.text;
424
+ if (config.format === 'json') {
425
+ try {
426
+ const { parseJSON } = require('./llm');
427
+ output = parseJSON(response.text);
428
+ } catch (e) {
429
+ console.warn(` [MD Agent: ${agentName}] Failed to parse JSON output`);
430
+ }
431
+ }
432
+
433
+ const { parseInteractionRequest } = require('./llm');
434
+
435
+ const explicitInteraction =
436
+ config.format === 'interaction' ||
437
+ config.interaction === 'true' ||
438
+ (typeof config.interaction === 'string' && config.interaction.length > 0);
439
+
440
+ // Structured interaction detection: LLM responds with { "interact": "question" }
441
+ const parsedInteraction = parseInteractionRequest(response.text);
442
+ const structuredInteraction =
443
+ config.autoInteract !== 'false' && parsedInteraction.isInteraction;
444
+
445
+ if (explicitInteraction || structuredInteraction) {
446
+ const slug =
447
+ (typeof config.interaction === 'string' && config.interaction !== 'true' ? config.interaction : null) ||
448
+ config.interactionSlug ||
449
+ config.interactionKey ||
450
+ outputKey ||
451
+ agentName;
452
+
453
+ const targetKey = config.interactionKey || outputKey || slug;
454
+
455
+ // Use parsed question for content if structured interaction, otherwise full response
456
+ const interactionContent = structuredInteraction
457
+ ? parsedInteraction.question
458
+ : response.text;
459
+
460
+ return {
461
+ ...context,
462
+ _interaction: {
463
+ slug,
464
+ targetKey,
465
+ content: interactionContent
466
+ },
467
+ [`_${agentName}_model`]: response.model
468
+ };
469
+ }
470
+
471
+ return {
472
+ ...context,
473
+ [outputKey]: output,
474
+ [`_${agentName}_model`]: response.model
475
+ };
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Load steering configuration and global prompt
481
+ */
482
+ loadSteering() {
483
+ this.steering = {
484
+ enabled: false,
485
+ global: null,
486
+ config: null
487
+ };
488
+
489
+ if (!fs.existsSync(this.steeringDir)) {
490
+ return;
491
+ }
492
+
493
+ // Load steering config
494
+ const configPath = path.join(this.steeringDir, 'config.json');
495
+ if (fs.existsSync(configPath)) {
496
+ try {
497
+ this.steering.config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
498
+ this.steering.enabled = this.steering.config.enabled !== false;
499
+ } catch (err) {
500
+ console.warn(`Warning: Failed to load steering config: ${err.message}`);
501
+ }
502
+ }
503
+
504
+ // Load global.md if present
505
+ const globalPath = path.join(this.steeringDir, 'global.md');
506
+ if (fs.existsSync(globalPath)) {
507
+ try {
508
+ this.steering.global = fs.readFileSync(globalPath, 'utf-8');
509
+ } catch (err) {
510
+ console.warn(`Warning: Failed to load global.md: ${err.message}`);
511
+ }
512
+ }
513
+ }
514
+
515
+ /**
516
+ * Get agent by name (custom first, then builtin)
517
+ */
518
+ getAgent(name) {
519
+ return this.customAgents[name] || BUILTIN_AGENTS[name];
520
+ }
521
+
522
+ /**
523
+ * Parse step definition (string format)
524
+ */
525
+ parseStep(step) {
526
+ if (typeof step !== 'string') {
527
+ return null; // Not a simple step
528
+ }
529
+ if (step.startsWith('agent:')) {
530
+ return { type: 'agent', name: step.slice(6) };
531
+ } else if (step.startsWith('script:')) {
532
+ return { type: 'script', path: step.slice(7) };
533
+ } else {
534
+ // Default to agent if no prefix
535
+ return { type: 'agent', name: step };
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Get step name for display
541
+ */
542
+ getStepName(step) {
543
+ if (typeof step === 'string') {
544
+ return step;
545
+ }
546
+ if (step.if) {
547
+ return `conditional`;
548
+ }
549
+ if (step.forEach) {
550
+ return `forEach`;
551
+ }
552
+ return 'unknown';
553
+ }
554
+
555
+ /**
556
+ * Resolve goto target to step index
557
+ * Special values: 'end' exits the workflow
558
+ */
559
+ resolveGoto(goto, steps, currentIndex) {
560
+ // Special case: end the workflow
561
+ if (goto === 'end') {
562
+ return steps.length; // Return index past the end to exit
563
+ }
564
+
565
+ if (typeof goto === 'number') {
566
+ if (goto < 0) {
567
+ // Relative jump backward
568
+ return currentIndex + goto;
569
+ }
570
+ // Absolute index
571
+ return goto;
572
+ }
573
+
574
+ if (typeof goto === 'string') {
575
+ // Find step by name
576
+ const targetIndex = steps.findIndex(s => {
577
+ if (typeof s === 'string') {
578
+ return s === goto;
579
+ }
580
+ return false;
581
+ });
582
+
583
+ if (targetIndex === -1) {
584
+ throw new Error(`Goto target not found: "${goto}". Step name does not exist in workflow.`);
585
+ }
586
+ return targetIndex;
587
+ }
588
+
589
+ throw new Error(`Invalid goto target: ${goto}. Must be a number, step name, or 'end'.`);
590
+ }
591
+
592
+ /**
593
+ * Execute an agent
594
+ */
595
+ async executeAgent(agentName, context) {
596
+ console.log(`\n▶ Executing agent: ${agentName}`);
597
+
598
+ const agent = this.getAgent(agentName);
599
+ if (!agent) {
600
+ const availableAgents = [
601
+ ...Object.keys(this.customAgents),
602
+ ...Object.keys(BUILTIN_AGENTS)
603
+ ].join(', ');
604
+ throw new Error(`Unknown agent: ${agentName}. Available agents: ${availableAgents}`);
605
+ }
606
+
607
+ // Inject steering and loop info into context
608
+ let agentContext = { ...context };
609
+ if (this.steering && this.steering.enabled && this.steering.global) {
610
+ agentContext._steering = {
611
+ global: this.steering.global,
612
+ config: this.steering.config
613
+ };
614
+ }
615
+ agentContext._loop = { ...this.loop };
616
+
617
+ // Inject config for llm() helper
618
+ agentContext._config = {
619
+ models: this.workflowConfig?.models || {},
620
+ apiKeys: this.workflowConfig?.apiKeys || {},
621
+ workflowDir: this.workflowDir
622
+ };
623
+
624
+ const result = await agent(agentContext);
625
+ console.log(`✓ Agent ${agentName} completed`);
626
+
627
+ // Remove internal props from result
628
+ if (result) {
629
+ delete result._steering;
630
+ delete result._loop;
631
+ delete result._config;
632
+ }
633
+
634
+ return result;
635
+ }
636
+
637
+ /**
638
+ * Execute a script
639
+ */
640
+ async executeScript(scriptPath, context) {
641
+ const resolvedPath = path.join(this.scriptsDir, scriptPath);
642
+ console.log(`\n▶ Executing script: ${scriptPath}`);
643
+
644
+ if (!fs.existsSync(resolvedPath)) {
645
+ throw new Error(`Script not found: ${resolvedPath}`);
646
+ }
647
+
648
+ // Inject steering into context for scripts too
649
+ let scriptContext = { ...context };
650
+ if (this.steering && this.steering.enabled && this.steering.global) {
651
+ scriptContext._steering = {
652
+ global: this.steering.global,
653
+ config: this.steering.config
654
+ };
655
+ }
656
+ scriptContext._loop = { ...this.loop };
657
+
658
+ // Inject config for scripts
659
+ scriptContext._config = {
660
+ models: this.workflowConfig?.models || {},
661
+ apiKeys: this.workflowConfig?.apiKeys || {},
662
+ workflowDir: this.workflowDir
663
+ };
664
+
665
+ return new Promise((resolve, reject) => {
666
+ const child = spawn('node', [resolvedPath], {
667
+ cwd: this.workflowDir,
668
+ env: {
669
+ ...process.env,
670
+ AGENT_CONTEXT: JSON.stringify(scriptContext),
671
+ AGENT_STEERING: this.steering?.global || '',
672
+ AGENT_LOOP_COUNT: String(this.loop.count),
673
+ WORKFLOW_DIR: this.workflowDir,
674
+ WORKFLOW_NAME: this.workflowName
675
+ },
676
+ stdio: ['pipe', 'pipe', 'pipe']
677
+ });
678
+
679
+ let stdout = '';
680
+ let stderr = '';
681
+
682
+ child.stdout.on('data', (data) => {
683
+ const text = data.toString();
684
+ stdout += text;
685
+ process.stdout.write(text);
686
+ });
687
+
688
+ child.stderr.on('data', (data) => {
689
+ const text = data.toString();
690
+ stderr += text;
691
+ process.stderr.write(text);
692
+ });
693
+
694
+ child.on('close', (code) => {
695
+ if (code === 0) {
696
+ console.log(`✓ Script ${scriptPath} completed`);
697
+ // Try to parse last line as JSON result
698
+ const lines = stdout.trim().split('\n');
699
+ const lastLine = lines[lines.length - 1];
700
+ try {
701
+ const result = JSON.parse(lastLine);
702
+ resolve({ ...context, ...result });
703
+ } catch {
704
+ resolve({ ...context, scriptOutput: stdout.trim() });
705
+ }
706
+ } else {
707
+ reject(new Error(`Script exited with code ${code}: ${stderr}`));
708
+ }
709
+ });
710
+
711
+ child.on('error', reject);
712
+ });
713
+ }
714
+
715
+ /**
716
+ * Execute a simple step (agent or script)
717
+ */
718
+ async executeSimpleStep(step, context) {
719
+ const parsed = this.parseStep(step);
720
+
721
+ if (parsed.type === 'agent') {
722
+ return this.executeAgent(parsed.name, context);
723
+ } else if (parsed.type === 'script') {
724
+ return this.executeScript(parsed.path, context);
725
+ }
726
+
727
+ throw new Error(`Unknown step type: ${parsed.type}`);
728
+ }
729
+
730
+ /**
731
+ * Execute a forEach step
732
+ */
733
+ async executeForEach(step, context, state, workflowName) {
734
+ const items = step.forEach(context);
735
+ const itemName = step.as || 'item';
736
+ const subSteps = step.steps || [];
737
+ const parallel = step.parallel || false;
738
+
739
+ if (!Array.isArray(items)) {
740
+ throw new Error(`forEach must return an array, got: ${typeof items}`);
741
+ }
742
+
743
+ console.log(`\n▶ forEach: ${items.length} items (${parallel ? 'parallel' : 'sequential'})`);
744
+
745
+ let currentContext = { ...context };
746
+
747
+ if (parallel) {
748
+ // Parallel execution
749
+ const results = await Promise.all(items.map(async (item, itemIndex) => {
750
+ let itemContext = { ...currentContext, [itemName]: item, [`${itemName}Index`]: itemIndex };
751
+
752
+ for (const subStep of subSteps) {
753
+ const result = await this.executeStepWithControl(subStep, itemContext, state, workflowName, subSteps, 0);
754
+ itemContext = result.context;
755
+ }
756
+
757
+ return itemContext;
758
+ }));
759
+
760
+ // Merge results (last item's context wins for conflicts)
761
+ currentContext = results.reduce((acc, ctx) => ({ ...acc, ...ctx }), currentContext);
762
+ } else {
763
+ // Sequential execution
764
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex++) {
765
+ const item = items[itemIndex];
766
+ currentContext[itemName] = item;
767
+ currentContext[`${itemName}Index`] = itemIndex;
768
+
769
+ console.log(`\n [${itemIndex + 1}/${items.length}] Processing ${itemName}`);
770
+
771
+ for (const subStep of subSteps) {
772
+ const result = await this.executeStepWithControl(subStep, currentContext, state, workflowName, subSteps, 0);
773
+ currentContext = result.context;
774
+ }
775
+ }
776
+ }
777
+
778
+ // Clean up iteration variables
779
+ delete currentContext[itemName];
780
+ delete currentContext[`${itemName}Index`];
781
+
782
+ console.log(`✓ forEach completed`);
783
+ return currentContext;
784
+ }
785
+
786
+ /**
787
+ * Execute a step and handle control flow (conditionals, goto, forEach)
788
+ * Returns: { context, goto: number | null }
789
+ */
790
+ async executeStepWithControl(step, context, state, workflowName, steps, currentIndex) {
791
+ // Handle conditional step
792
+ if (step.if && typeof step.if === 'function') {
793
+ console.log(`\n▶ Evaluating conditional...`);
794
+
795
+ const condition = step.if(context, this.loop);
796
+ console.log(` Condition result: ${condition}`);
797
+
798
+ const branch = condition ? step.true : step.false;
799
+
800
+ if (branch && branch.goto !== undefined) {
801
+ const targetIndex = this.resolveGoto(branch.goto, steps, currentIndex);
802
+ console.log(` → Jumping to step ${targetIndex}`);
803
+ return { context, goto: targetIndex };
804
+ }
805
+
806
+ // No goto, just continue
807
+ return { context, goto: null };
808
+ }
809
+
810
+ // Handle forEach step
811
+ if (step.forEach && typeof step.forEach === 'function') {
812
+ const newContext = await this.executeForEach(step, context, state, workflowName);
813
+ return { context: newContext, goto: null };
814
+ }
815
+
816
+ // Handle simple step (string)
817
+ if (typeof step === 'string') {
818
+ const newContext = await this.executeSimpleStep(step, context);
819
+ return { context: newContext, goto: null };
820
+ }
821
+
822
+ throw new Error(`Unknown step format: ${JSON.stringify(step)}`);
823
+ }
824
+
825
+ /**
826
+ * Run the workflow
827
+ */
828
+ async run() {
829
+ this.ensureWorkflow();
830
+
831
+ // Load workflow config
832
+ const config = this.loadWorkflowConfig();
833
+ this.workflowConfig = config; // Store for access in agents
834
+ const steps = config.steps || [];
835
+ const workflowName = config.name || this.workflowName;
836
+
837
+ // Load custom agents
838
+ this.loadCustomAgents();
839
+
840
+ // Load steering configuration
841
+ this.loadSteering();
842
+
843
+ if (this.steering.enabled && this.steering.global) {
844
+ console.log(`Steering: global.md loaded (${this.steering.global.length} chars)`);
845
+ }
846
+
847
+ console.log(`\n${'═'.repeat(50)}`);
848
+ console.log(`Starting workflow: ${workflowName}`);
849
+ console.log(`Steps: ${steps.length}`);
850
+ console.log(`${'═'.repeat(50)}`);
851
+
852
+ // Initialize state
853
+ let state = this.loadCurrentState();
854
+ state.status = States.RUNNING;
855
+ state.workflow = { name: workflowName, stepCount: steps.length };
856
+ state.currentStepIndex = 0;
857
+ state.context = config.initialContext || {};
858
+ state.startedAt = new Date().toISOString();
859
+ state.error = null;
860
+
861
+ // Initialize loop tracking
862
+ this.loop = {
863
+ count: 0,
864
+ stepCounts: {}
865
+ };
866
+
867
+ this.saveCurrentState(state);
868
+
869
+ this.prependHistory({
870
+ event: 'WORKFLOW_STARTED',
871
+ workflow: workflowName,
872
+ steps: steps.length
873
+ });
874
+
875
+ // Execute steps with goto support
876
+ let i = 0;
877
+ const maxIterations = 10000; // Safety limit
878
+ let iterations = 0;
879
+
880
+ while (i < steps.length && iterations < maxIterations) {
881
+ iterations++;
882
+ this.loop.count = iterations;
883
+
884
+ const step = steps[i];
885
+ const stepName = this.getStepName(step);
886
+
887
+ // Track step execution count
888
+ this.loop.stepCounts[i] = (this.loop.stepCounts[i] || 0) + 1;
889
+
890
+ state.currentStepIndex = i;
891
+ state.status = States.STEP_EXECUTING;
892
+ state.context = { ...state.context }; // Ensure we're saving current context
893
+ this.saveCurrentState(state);
894
+
895
+ console.log(`\n${'─'.repeat(40)}`);
896
+ console.log(`Step ${i + 1}/${steps.length}: ${stepName} (iteration ${iterations}, step runs: ${this.loop.stepCounts[i]})`);
897
+ console.log(`${'─'.repeat(40)}`);
898
+
899
+ this.prependHistory({
900
+ event: 'STEP_STARTED',
901
+ workflow: workflowName,
902
+ step: stepName,
903
+ stepIndex: i,
904
+ loopCount: this.loop.count,
905
+ stepRunCount: this.loop.stepCounts[i]
906
+ });
907
+
908
+ try {
909
+ const result = await this.executeStepWithControl(step, state.context, state, workflowName, steps, i);
910
+
911
+ state.context = await this.handleInteractionIfNeeded(
912
+ result.context,
913
+ state,
914
+ workflowName,
915
+ stepName,
916
+ i
917
+ );
918
+
919
+ if (state.status === States.PAUSED) {
920
+ return state.context;
921
+ }
922
+
923
+ state.status = States.STEP_COMPLETED;
924
+ this.saveCurrentState(state);
925
+
926
+ this.prependHistory({
927
+ event: 'STEP_COMPLETED',
928
+ workflow: workflowName,
929
+ step: stepName,
930
+ stepIndex: i,
931
+ context: state.context
932
+ });
933
+
934
+ // Handle goto
935
+ if (result.goto !== null) {
936
+ if (result.goto < 0 || result.goto > steps.length) {
937
+ throw new Error(`Goto index out of bounds: ${result.goto}. Valid range: 0-${steps.length}`);
938
+ }
939
+ i = result.goto;
940
+ } else {
941
+ i++;
942
+ }
943
+
944
+ } catch (error) {
945
+ console.error(`\n✗ Step failed: ${error.message}`);
946
+ state.status = States.STEP_FAILED;
947
+ state.error = {
948
+ step: stepName,
949
+ stepIndex: i,
950
+ message: error.message,
951
+ stack: error.stack
952
+ };
953
+ this.saveCurrentState(state);
954
+
955
+ this.prependHistory({
956
+ event: 'STEP_FAILED',
957
+ workflow: workflowName,
958
+ step: stepName,
959
+ stepIndex: i,
960
+ error: error.message
961
+ });
962
+
963
+ // Mark workflow as failed
964
+ state.status = States.WORKFLOW_FAILED;
965
+ this.saveCurrentState(state);
966
+
967
+ this.prependHistory({
968
+ event: 'WORKFLOW_FAILED',
969
+ workflow: workflowName,
970
+ failedStep: stepName,
971
+ failedStepIndex: i,
972
+ error: error.message
973
+ });
974
+
975
+ console.log(`\n${'═'.repeat(50)}`);
976
+ console.log(`✗ Workflow failed at step ${i + 1}`);
977
+ console.log(`${'═'.repeat(50)}\n`);
978
+
979
+ process.exitCode = 1;
980
+ throw error;
981
+ }
982
+ }
983
+
984
+ if (iterations >= maxIterations) {
985
+ const error = new Error(`Workflow exceeded maximum iterations (${maxIterations}). Possible infinite loop.`);
986
+ state.status = States.WORKFLOW_FAILED;
987
+ state.error = { message: error.message };
988
+ this.saveCurrentState(state);
989
+
990
+ this.prependHistory({
991
+ event: 'WORKFLOW_FAILED',
992
+ workflow: workflowName,
993
+ error: error.message
994
+ });
995
+
996
+ process.exitCode = 1;
997
+ throw error;
998
+ }
999
+
1000
+ // Workflow completed
1001
+ state.status = States.WORKFLOW_COMPLETED;
1002
+ this.saveCurrentState(state);
1003
+
1004
+ this.prependHistory({
1005
+ event: 'WORKFLOW_COMPLETED',
1006
+ workflow: workflowName,
1007
+ finalContext: state.context,
1008
+ totalIterations: iterations
1009
+ });
1010
+
1011
+ console.log(`\n${'═'.repeat(50)}`);
1012
+ console.log(`✓ Workflow completed successfully (${iterations} iterations)`);
1013
+ console.log(`${'═'.repeat(50)}`);
1014
+ console.log('\nFinal context:');
1015
+ console.log(JSON.stringify(state.context, null, 2));
1016
+
1017
+ return state.context;
1018
+ }
1019
+
1020
+ /**
1021
+ * Show current status
1022
+ */
1023
+ showStatus() {
1024
+ if (!this.workflowName) {
1025
+ console.log('\nNo workflow specified. Use: state-machine status <workflow-name>');
1026
+ return;
1027
+ }
1028
+
1029
+ try {
1030
+ this.ensureWorkflow();
1031
+ } catch (err) {
1032
+ console.error(err.message);
1033
+ return;
1034
+ }
1035
+
1036
+ const state = this.loadCurrentState();
1037
+ console.log(`\nWorkflow: ${this.workflowName}`);
1038
+ console.log('Current State:');
1039
+ console.log('─'.repeat(40));
1040
+ console.log(JSON.stringify(state, null, 2));
1041
+ }
1042
+
1043
+ /**
1044
+ * Show history
1045
+ */
1046
+ showHistory(limit = 20) {
1047
+ if (!this.workflowName) {
1048
+ console.log('\nNo workflow specified. Use: state-machine history <workflow-name>');
1049
+ return;
1050
+ }
1051
+
1052
+ try {
1053
+ this.ensureWorkflow();
1054
+ } catch (err) {
1055
+ console.error(err.message);
1056
+ return;
1057
+ }
1058
+
1059
+ const history = this.loadHistory();
1060
+ const entries = history.slice(-limit);
1061
+
1062
+ console.log(`\nWorkflow: ${this.workflowName}`);
1063
+ console.log(`Execution History (last ${entries.length} entries):`);
1064
+ console.log('─'.repeat(60));
1065
+
1066
+ if (entries.length === 0) {
1067
+ console.log('No history yet.');
1068
+ return;
1069
+ }
1070
+
1071
+ entries.forEach((entry) => {
1072
+ const time = new Date(entry.timestamp).toLocaleString();
1073
+ console.log(`\n[${time}] ${entry.event}`);
1074
+ if (entry.step) console.log(` Step: ${entry.step}`);
1075
+ if (entry.loopCount) console.log(` Loop: ${entry.loopCount}`);
1076
+ if (entry.error) console.log(` Error: ${entry.error}`);
1077
+ });
1078
+ console.log('');
1079
+ }
1080
+
1081
+ /**
1082
+ * Reset state
1083
+ */
1084
+ reset() {
1085
+ if (!this.workflowName) {
1086
+ console.log('\nNo workflow specified. Use: state-machine reset <workflow-name>');
1087
+ return;
1088
+ }
1089
+
1090
+ try {
1091
+ this.ensureWorkflow();
1092
+ } catch (err) {
1093
+ console.error(err.message);
1094
+ return;
1095
+ }
1096
+
1097
+ const state = this.createInitialState();
1098
+ this.saveCurrentState(state);
1099
+ console.log(`Workflow '${this.workflowName}' state reset to initial values`);
1100
+ }
1101
+
1102
+ /**
1103
+ * Resume a failed or stopped workflow from where it left off
1104
+ */
1105
+ async resume() {
1106
+ this.ensureWorkflow();
1107
+
1108
+ // Load saved state
1109
+ const savedState = this.loadCurrentState();
1110
+
1111
+ // Check if workflow can be resumed
1112
+ if (savedState.status === 'IDLE') {
1113
+ console.log('No workflow to resume. Use `state-machine run` to start.');
1114
+ return;
1115
+ }
1116
+
1117
+ if (savedState.status === 'WORKFLOW_COMPLETED') {
1118
+ console.log('Workflow already completed. Use `state-machine run` to start fresh.');
1119
+ return;
1120
+ }
1121
+
1122
+ if (!savedState.workflow) {
1123
+ console.log('No workflow state found. Use `state-machine run` to start.');
1124
+ return;
1125
+ }
1126
+
1127
+ // Load workflow config
1128
+ const config = this.loadWorkflowConfig();
1129
+ this.workflowConfig = config;
1130
+ const steps = config.steps || [];
1131
+ const workflowName = config.name || this.workflowName;
1132
+
1133
+ // Load custom agents and steering
1134
+ this.loadCustomAgents();
1135
+ this.loadSteering();
1136
+
1137
+ if (this.steering.enabled && this.steering.global) {
1138
+ console.log(`Steering: global.md loaded (${this.steering.global.length} chars)`);
1139
+ }
1140
+
1141
+ // If paused for an interaction, finalize it and move on
1142
+ if (savedState.status === States.PAUSED && savedState.pendingInteraction) {
1143
+ const pending = savedState.pendingInteraction;
1144
+ const filePath = path.join(this.workflowDir, pending.file);
1145
+ if (fs.existsSync(filePath)) {
1146
+ const content = fs.readFileSync(filePath, 'utf-8');
1147
+ savedState.context = { ...savedState.context, [pending.targetKey]: content };
1148
+ }
1149
+
1150
+ savedState.pendingInteraction = null;
1151
+ savedState.status = States.STEP_COMPLETED;
1152
+ this.saveCurrentState(savedState);
1153
+
1154
+ this.prependHistory({
1155
+ event: 'INTERACTION_RESOLVED',
1156
+ workflow: workflowName,
1157
+ step: pending.step,
1158
+ stepIndex: pending.stepIndex,
1159
+ slug: pending.slug,
1160
+ file: pending.file,
1161
+ targetKey: pending.targetKey,
1162
+ resumed: true
1163
+ });
1164
+ }
1165
+
1166
+ // Determine resume point
1167
+ let resumeIndex = savedState.currentStepIndex;
1168
+
1169
+ // If the step failed, retry it; otherwise start from the next one
1170
+ if (savedState.status === 'STEP_FAILED' || savedState.status === 'WORKFLOW_FAILED') {
1171
+ console.log(`\nResuming from failed step ${resumeIndex + 1}...`);
1172
+ } else if (savedState.status === 'STEP_COMPLETED') {
1173
+ resumeIndex = savedState.currentStepIndex + 1;
1174
+ console.log(`\nResuming from step ${resumeIndex + 1}...`);
1175
+ } else {
1176
+ console.log(`\nResuming from step ${resumeIndex + 1} (status: ${savedState.status})...`);
1177
+ }
1178
+
1179
+ // Check if there are steps to run
1180
+ if (resumeIndex >= steps.length) {
1181
+ console.log('No more steps to run. Workflow complete.');
1182
+ savedState.status = States.WORKFLOW_COMPLETED;
1183
+ this.saveCurrentState(savedState);
1184
+ return savedState.context;
1185
+ }
1186
+
1187
+ console.log(`\n${'═'.repeat(50)}`);
1188
+ console.log(`Resuming workflow: ${workflowName}`);
1189
+ console.log(`From step: ${resumeIndex + 1}/${steps.length}`);
1190
+ console.log(`Previous context keys: ${Object.keys(savedState.context).filter(k => !k.startsWith('_')).join(', ') || 'none'}`);
1191
+ console.log(`${'═'.repeat(50)}`);
1192
+
1193
+ // Restore state
1194
+ let state = savedState;
1195
+ state.status = States.RUNNING;
1196
+ state.error = null;
1197
+
1198
+ // Restore loop tracking
1199
+ this.loop = savedState.loop || {
1200
+ count: 0,
1201
+ stepCounts: {}
1202
+ };
1203
+
1204
+ this.saveCurrentState(state);
1205
+
1206
+ this.prependHistory({
1207
+ event: 'WORKFLOW_RESUMED',
1208
+ workflow: workflowName,
1209
+ resumeFromStep: resumeIndex,
1210
+ previousStatus: savedState.status
1211
+ });
1212
+
1213
+ // Execute remaining steps
1214
+ let i = resumeIndex;
1215
+ const maxIterations = 10000;
1216
+ let iterations = this.loop.count || 0;
1217
+
1218
+ while (i < steps.length && iterations < maxIterations) {
1219
+ iterations++;
1220
+ this.loop.count = iterations;
1221
+
1222
+ const step = steps[i];
1223
+ const stepName = this.getStepName(step);
1224
+
1225
+ // Track step execution count
1226
+ this.loop.stepCounts[i] = (this.loop.stepCounts[i] || 0) + 1;
1227
+
1228
+ state.currentStepIndex = i;
1229
+ state.status = States.STEP_EXECUTING;
1230
+ this.saveCurrentState(state);
1231
+
1232
+ console.log(`\n${'─'.repeat(40)}`);
1233
+ console.log(`Step ${i + 1}/${steps.length}: ${stepName} (iteration ${iterations}, step runs: ${this.loop.stepCounts[i]})`);
1234
+ console.log(`${'─'.repeat(40)}`);
1235
+
1236
+ this.prependHistory({
1237
+ event: 'STEP_STARTED',
1238
+ workflow: workflowName,
1239
+ step: stepName,
1240
+ stepIndex: i,
1241
+ loopCount: this.loop.count,
1242
+ stepRunCount: this.loop.stepCounts[i],
1243
+ resumed: true
1244
+ });
1245
+
1246
+ try {
1247
+ const result = await this.executeStepWithControl(step, state.context, state, workflowName, steps, i);
1248
+
1249
+ state.context = await this.handleInteractionIfNeeded(
1250
+ result.context,
1251
+ state,
1252
+ workflowName,
1253
+ stepName,
1254
+ i
1255
+ );
1256
+
1257
+ if (state.status === States.PAUSED) {
1258
+ return state.context;
1259
+ }
1260
+
1261
+ state.status = States.STEP_COMPLETED;
1262
+ this.saveCurrentState(state);
1263
+
1264
+ this.prependHistory({
1265
+ event: 'STEP_COMPLETED',
1266
+ workflow: workflowName,
1267
+ step: stepName,
1268
+ stepIndex: i,
1269
+ context: state.context
1270
+ });
1271
+
1272
+ // Handle goto
1273
+ if (result.goto !== null) {
1274
+ if (result.goto < 0 || result.goto > steps.length) {
1275
+ throw new Error(`Goto index out of bounds: ${result.goto}. Valid range: 0-${steps.length}`);
1276
+ }
1277
+ i = result.goto;
1278
+ } else {
1279
+ i++;
1280
+ }
1281
+
1282
+ } catch (error) {
1283
+ console.error(`\n✗ Step failed: ${error.message}`);
1284
+ state.status = States.STEP_FAILED;
1285
+ state.error = {
1286
+ step: stepName,
1287
+ stepIndex: i,
1288
+ message: error.message,
1289
+ stack: error.stack
1290
+ };
1291
+ this.saveCurrentState(state);
1292
+
1293
+ this.prependHistory({
1294
+ event: 'STEP_FAILED',
1295
+ workflow: workflowName,
1296
+ step: stepName,
1297
+ stepIndex: i,
1298
+ error: error.message
1299
+ });
1300
+
1301
+ state.status = States.WORKFLOW_FAILED;
1302
+ this.saveCurrentState(state);
1303
+
1304
+ this.prependHistory({
1305
+ event: 'WORKFLOW_FAILED',
1306
+ workflow: workflowName,
1307
+ failedStep: stepName,
1308
+ failedStepIndex: i,
1309
+ error: error.message
1310
+ });
1311
+
1312
+ console.log(`\n${'═'.repeat(50)}`);
1313
+ console.log(`✗ Workflow failed at step ${i + 1}`);
1314
+ console.log(`Use 'state-machine resume ${this.workflowName}' to retry`);
1315
+ console.log(`${'═'.repeat(50)}\n`);
1316
+
1317
+ process.exitCode = 1;
1318
+ throw error;
1319
+ }
1320
+ }
1321
+
1322
+ if (iterations >= maxIterations) {
1323
+ const error = new Error(`Workflow exceeded maximum iterations (${maxIterations}). Possible infinite loop.`);
1324
+ state.status = States.WORKFLOW_FAILED;
1325
+ state.error = { message: error.message };
1326
+ this.saveCurrentState(state);
1327
+
1328
+ this.prependHistory({
1329
+ event: 'WORKFLOW_FAILED',
1330
+ workflow: workflowName,
1331
+ error: error.message
1332
+ });
1333
+
1334
+ process.exitCode = 1;
1335
+ throw error;
1336
+ }
1337
+
1338
+ // Workflow completed
1339
+ state.status = States.WORKFLOW_COMPLETED;
1340
+ this.saveCurrentState(state);
1341
+
1342
+ this.prependHistory({
1343
+ event: 'WORKFLOW_COMPLETED',
1344
+ workflow: workflowName,
1345
+ finalContext: state.context,
1346
+ totalIterations: iterations,
1347
+ resumed: true
1348
+ });
1349
+
1350
+ console.log(`\n${'═'.repeat(50)}`);
1351
+ console.log(`✓ Workflow completed successfully (${iterations} iterations)`);
1352
+ console.log(`${'═'.repeat(50)}`);
1353
+ console.log('\nFinal context:');
1354
+ console.log(JSON.stringify(state.context, null, 2));
1355
+
1356
+ return state.context;
1357
+ }
1358
+ }
1359
+ export { StateMachine, States, BUILTIN_AGENTS };