@reactive-skills/runtime 0.1.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,648 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import yaml from 'js-yaml';
4
+ import Handlebars from 'handlebars';
5
+ import { SkillManifestSchema, } from './types.js';
6
+ import { EventStore, createSortableId } from './event-store.js';
7
+ import { GuardEvaluator } from './guard-evaluator.js';
8
+ import { LegacySkillAdapter } from './legacy-adapter.js';
9
+ import { ProjectionEngine } from './projection-engine.js';
10
+ /** Maximum lifecycle-signal drain steps per top-level handleSignal call.
11
+ * Prevents cyclic on_enter/on_exit emissions from overflowing the call stack (REL-02). */
12
+ const MAX_QUEUE_DRAIN_DEPTH = 50;
13
+ export class FSMEngine {
14
+ skillDir;
15
+ workspaceDir;
16
+ manifest;
17
+ activeStatePath = [];
18
+ context;
19
+ eventStore;
20
+ projectionEngine;
21
+ signalQueue = [];
22
+ isProcessingQueue = false;
23
+ signalQueueDepth = 0;
24
+ turnsSinceLastSignal;
25
+ inBypassState;
26
+ strictExecution;
27
+ constructor(options) {
28
+ this.skillDir = path.resolve(options.skillDir);
29
+ this.workspaceDir = options.workspaceDir || process.cwd();
30
+ this.manifest = this.loadManifest();
31
+ this.strictExecution = this.manifest.strict_execution === true;
32
+ this.turnsSinceLastSignal = 0;
33
+ this.inBypassState = false;
34
+ this.eventStore = options.eventStore || new EventStore({
35
+ skillId: this.manifest.name,
36
+ workspaceDir: this.workspaceDir,
37
+ runId: options.runId,
38
+ enableSqlite: true,
39
+ ...options.eventContext,
40
+ });
41
+ this.context = {
42
+ ...(this.manifest.default_context || {}),
43
+ ...(options.initialContext || {}),
44
+ };
45
+ this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd());
46
+ const autoRehydrate = options.autoRehydrate !== false;
47
+ const latestSnapshot = autoRehydrate ? this.eventStore.getLatestSnapshot() : null;
48
+ const history = autoRehydrate
49
+ ? (latestSnapshot ? this.eventStore.getSince(latestSnapshot.seq) : this.eventStore.getAll())
50
+ : [];
51
+ if (autoRehydrate && (latestSnapshot || history.length > 0)) {
52
+ this.rehydrate(history, latestSnapshot);
53
+ }
54
+ else {
55
+ const initialPath = this.resolveInitialPath([this.manifest.initial_state]);
56
+ this.activeStatePath = [...initialPath];
57
+ this.eventStore.append('SKILL_INITIALIZED', {
58
+ skill: this.manifest.name,
59
+ initial_state: initialPath.join('.'),
60
+ active_path: initialPath,
61
+ context: this.context,
62
+ }, { state: initialPath.join('.') });
63
+ this.enterPath(initialPath, [], 'INITIAL_BOOT');
64
+ this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
65
+ }
66
+ }
67
+ /**
68
+ * Rehydrate state machine state and context from immutable event history,
69
+ * resuming from the latest point-in-time state snapshot if available (PERF-02 / INV-08)
70
+ */
71
+ rehydrate(events, snapshot) {
72
+ const snap = snapshot !== undefined ? snapshot : this.eventStore.getLatestSnapshot();
73
+ let latestPath;
74
+ let eventsToReplay = events;
75
+ if (snap) {
76
+ latestPath = snap.state.split('.');
77
+ this.context = { ...this.context, ...snap.context };
78
+ eventsToReplay = events.filter(e => e.seq > snap.seq);
79
+ }
80
+ else {
81
+ latestPath = this.resolveInitialPath([this.manifest.initial_state]);
82
+ }
83
+ for (const e of eventsToReplay) {
84
+ if (e.type === 'SKILL_INITIALIZED' && e.payload?.active_path) {
85
+ latestPath = e.payload.active_path;
86
+ if (e.payload.context) {
87
+ this.context = { ...this.context, ...e.payload.context };
88
+ }
89
+ }
90
+ else if (e.type === 'STATE_TRANSITION' && e.payload?.to) {
91
+ latestPath = e.payload.to.split('.');
92
+ }
93
+ else if (e.type === 'STATE_ENTRY_HOOK' && e.payload?.action?.set_context) {
94
+ this.updateContext(e.payload.action.set_context);
95
+ }
96
+ else if (e.type === 'SIGNAL_EMITTED') {
97
+ if (this.manifest.context_keys) {
98
+ const incomingContextUpdates = {};
99
+ for (const key of this.manifest.context_keys) {
100
+ if (e.payload[key] !== undefined) {
101
+ incomingContextUpdates[key] = e.payload[key];
102
+ }
103
+ }
104
+ if (Object.keys(incomingContextUpdates).length > 0) {
105
+ this.updateContext(incomingContextUpdates);
106
+ }
107
+ }
108
+ if (e.payload?.contextUpdates) {
109
+ this.updateContext(e.payload.contextUpdates);
110
+ }
111
+ }
112
+ else if (e.payload?.contextUpdates) {
113
+ this.updateContext(e.payload.contextUpdates);
114
+ }
115
+ }
116
+ this.activeStatePath = latestPath;
117
+ // Recompute turn counter from event history
118
+ this.turnsSinceLastSignal = 0;
119
+ this.inBypassState = false;
120
+ for (const e of eventsToReplay) {
121
+ if (e.type === 'AGENT_TURN_STARTED') {
122
+ this.turnsSinceLastSignal++;
123
+ }
124
+ else if (e.type === 'SIGNAL_EMITTED' || e.type === 'STATE_TRANSITION') {
125
+ this.turnsSinceLastSignal = 0;
126
+ }
127
+ if (e.type === 'STATE_TRANSITION' && e.payload?.to === 'BYPASS_DETECTED') {
128
+ this.inBypassState = true;
129
+ }
130
+ }
131
+ // Restore turns_since_last_signal from snapshot context if available
132
+ if (snap?.context?.turns_since_last_signal !== undefined) {
133
+ this.turnsSinceLastSignal = snap.context.turns_since_last_signal;
134
+ }
135
+ }
136
+ loadManifest() {
137
+ const yamlPath = path.join(this.skillDir, 'skill.yaml');
138
+ if (!fs.existsSync(yamlPath)) {
139
+ throw new Error(`Reactive skill manifest not found: ${yamlPath}`);
140
+ }
141
+ const raw = fs.readFileSync(yamlPath, 'utf8');
142
+ const parsed = yaml.load(raw);
143
+ const validated = SkillManifestSchema.parse(parsed);
144
+ return validated;
145
+ }
146
+ getManifest() {
147
+ return this.manifest;
148
+ }
149
+ getCurrentState() {
150
+ return this.activeStatePath.join('.');
151
+ }
152
+ getActiveStatePath() {
153
+ return [...this.activeStatePath];
154
+ }
155
+ getContext() {
156
+ return { ...this.context };
157
+ }
158
+ updateContext(updates) {
159
+ this.context = { ...this.context, ...updates };
160
+ }
161
+ getEventStore() {
162
+ return this.eventStore;
163
+ }
164
+ getEventContext() {
165
+ return this.eventStore.getEventContext();
166
+ }
167
+ isStrictExecution() {
168
+ return this.strictExecution;
169
+ }
170
+ getTurnsSinceLastSignal() {
171
+ return this.turnsSinceLastSignal;
172
+ }
173
+ isBypassDetected() {
174
+ return this.inBypassState;
175
+ }
176
+ recordTurnStart() {
177
+ if (!this.strictExecution || this.inBypassState)
178
+ return;
179
+ this.eventStore.append('AGENT_TURN_STARTED', {
180
+ state: this.getCurrentState(),
181
+ turns_since_last_signal: this.turnsSinceLastSignal + 1,
182
+ }, { state: this.getCurrentState() });
183
+ this.turnsSinceLastSignal++;
184
+ this.checkBypass();
185
+ }
186
+ checkBypass() {
187
+ if (!this.strictExecution || this.inBypassState)
188
+ return;
189
+ const stateDef = this.getStateDefinition(this.activeStatePath);
190
+ const maxIdleTurns = stateDef?.max_idle_turns ?? 2;
191
+ if (this.turnsSinceLastSignal > maxIdleTurns) {
192
+ const bypassTarget = stateDef?.bypass_target ?? 'BYPASS_DETECTED';
193
+ this.eventStore.append('BYPASS_DETECTED', {
194
+ reason: 'Agent exceeded max idle turns without emitting a signal',
195
+ turns: this.turnsSinceLastSignal,
196
+ max_idle_turns: maxIdleTurns,
197
+ state: this.getCurrentState(),
198
+ }, { state: bypassTarget });
199
+ this.eventStore.append('STATE_TRANSITION', {
200
+ from: this.getCurrentState(),
201
+ to: bypassTarget,
202
+ signal: 'BYPASS_DETECTED',
203
+ payload: { reason: 'Agent exceeded max idle turns without emitting a signal' },
204
+ }, { state: bypassTarget });
205
+ this.inBypassState = true;
206
+ this.activeStatePath = [bypassTarget];
207
+ this.turnsSinceLastSignal = 0;
208
+ throw new Error(`BYPASS_DETECTED: Agent exceeded ${maxIdleTurns} idle turns without emitting a signal. ` +
209
+ `The runtime has entered the BYPASS_DETECTED state. ` +
210
+ `To recover, run: reactive-skills-axi reset ${this.manifest.name} then re-invoke.`);
211
+ }
212
+ }
213
+ recordChildRunStarted(childSkillId, childRunId, requestId) {
214
+ return this.eventStore.append('CHILD_RUN_STARTED', {
215
+ child_skill_id: childSkillId,
216
+ child_run_id: childRunId,
217
+ request_id: requestId,
218
+ });
219
+ }
220
+ recordChildRunCompleted(summary) {
221
+ const eventType = summary.outcome === 'failed' ? 'CHILD_RUN_FAILED' : 'CHILD_RUN_COMPLETED';
222
+ return this.eventStore.append(eventType, summary);
223
+ }
224
+ async invokeSkill(skillNameOrPath) {
225
+ const skillDir = path.resolve(this.workspaceDir || process.cwd(), skillNameOrPath);
226
+ const skillYamlPath = path.join(skillDir, 'skill.yaml');
227
+ let manifest;
228
+ if (fs.existsSync(skillYamlPath)) {
229
+ const raw = fs.readFileSync(skillYamlPath, 'utf8');
230
+ const parsed = yaml.load(raw);
231
+ manifest = SkillManifestSchema.parse(parsed);
232
+ }
233
+ else {
234
+ const skillMdPath = path.join(skillDir, 'SKILL.md');
235
+ if (!fs.existsSync(skillMdPath)) {
236
+ throw new Error(`Skill not found: ${skillNameOrPath} (no skill.yaml or SKILL.md at ${skillDir})`);
237
+ }
238
+ manifest = LegacySkillAdapter.wrapAsReactiveManifest(skillMdPath);
239
+ }
240
+ const childRunId = createSortableId();
241
+ const startEvent = this.recordChildRunStarted(manifest.name, childRunId);
242
+ let outcome = 'completed';
243
+ let childState = manifest.initial_state;
244
+ let promptSlice;
245
+ try {
246
+ const childEngine = new FSMEngine({
247
+ skillDir,
248
+ workspaceDir: this.workspaceDir,
249
+ runId: childRunId,
250
+ eventContext: {
251
+ skill_id: manifest.name,
252
+ parent_run_id: this.getEventContext().run_id,
253
+ },
254
+ initialContext: this.context,
255
+ });
256
+ childState = childEngine.getCurrentState();
257
+ promptSlice = childEngine.generatePromptSlice();
258
+ }
259
+ catch (err) {
260
+ outcome = 'failed';
261
+ promptSlice = {
262
+ state: manifest.initial_state,
263
+ rawPrompt: `Failed to invoke skill: ${err instanceof Error ? err.message : String(err)}`,
264
+ formattedXml: '',
265
+ allowedTools: [],
266
+ context: {},
267
+ exitConditions: [],
268
+ };
269
+ }
270
+ this.recordChildRunCompleted({
271
+ child_skill_id: manifest.name,
272
+ child_run_id: childRunId,
273
+ outcome,
274
+ failed_state: outcome === 'failed' ? childState : undefined,
275
+ summary: `Invoked ${manifest.name} at state ${childState}`,
276
+ evidence_ref: startEvent.id,
277
+ });
278
+ return {
279
+ skillId: manifest.name,
280
+ currentState: childState,
281
+ promptSlice,
282
+ event: startEvent,
283
+ };
284
+ }
285
+ recordDecision(decision, source = 'human_ingress') {
286
+ return this.eventStore.append('DECISION_RECORDED', decision, { source });
287
+ }
288
+ /**
289
+ * Resolves full hierarchical path including default/initial substates
290
+ */
291
+ resolveInitialPath(basePath) {
292
+ const fullPath = [...basePath];
293
+ let currentDef = this.getStateDefinition(fullPath);
294
+ while (currentDef && currentDef.initial_substate && currentDef.substates) {
295
+ const nextSub = currentDef.initial_substate;
296
+ fullPath.push(nextSub);
297
+ currentDef = currentDef.substates[nextSub];
298
+ }
299
+ return fullPath;
300
+ }
301
+ /**
302
+ * Get StateDefinition by path hierarchy
303
+ */
304
+ getStateDefinition(pathSegments) {
305
+ if (pathSegments.length === 0)
306
+ return null;
307
+ const rootName = pathSegments[0];
308
+ let current = this.manifest.states[rootName];
309
+ if (!current)
310
+ return null;
311
+ for (let i = 1; i < pathSegments.length; i++) {
312
+ const seg = pathSegments[i];
313
+ if (!current.substates || !current.substates[seg]) {
314
+ return null;
315
+ }
316
+ current = current.substates[seg];
317
+ }
318
+ return current;
319
+ }
320
+ /**
321
+ * Generates the prompt slice for the active state hierarchy
322
+ */
323
+ generatePromptSlice() {
324
+ const activeLeaf = this.getStateDefinition(this.activeStatePath);
325
+ if (!activeLeaf) {
326
+ throw new Error(`Active state definition not found: ${this.getCurrentState()}`);
327
+ }
328
+ // Find prompt template: check leaf first, then fallback to parent if leaf has none
329
+ let templatePath = null;
330
+ for (let i = this.activeStatePath.length; i >= 1; i--) {
331
+ const depthPath = this.activeStatePath.slice(0, i);
332
+ const def = this.getStateDefinition(depthPath);
333
+ // 1. Explicit prompt_template path
334
+ if (def?.prompt_template) {
335
+ const candidate = path.resolve(this.skillDir, def.prompt_template);
336
+ if (fs.existsSync(candidate)) {
337
+ templatePath = candidate;
338
+ break;
339
+ }
340
+ }
341
+ // 2. Convention over configuration fallback: states/<leaf_name_lowercase>.md
342
+ const leafName = depthPath[depthPath.length - 1].toLowerCase();
343
+ const conventionCandidates = [
344
+ path.resolve(this.skillDir, 'states', `${leafName}.md`),
345
+ path.resolve(this.skillDir, 'states', `${depthPath.join('_').toLowerCase()}.md`),
346
+ path.resolve(this.skillDir, `${leafName}.md`),
347
+ ];
348
+ for (const candidate of conventionCandidates) {
349
+ if (fs.existsSync(candidate)) {
350
+ templatePath = candidate;
351
+ break;
352
+ }
353
+ }
354
+ if (templatePath)
355
+ break;
356
+ }
357
+ let rawPrompt = '';
358
+ if (templatePath && fs.existsSync(templatePath)) {
359
+ const templateContent = fs.readFileSync(templatePath, 'utf8');
360
+ const compiled = Handlebars.compile(templateContent);
361
+ rawPrompt = compiled({
362
+ state: this.getCurrentState(),
363
+ activeStatePath: this.activeStatePath,
364
+ context: this.context,
365
+ manifest: this.manifest,
366
+ });
367
+ }
368
+ else {
369
+ rawPrompt = `Execute instructions for state: ${this.getCurrentState()}`;
370
+ }
371
+ // Aggregate allowed tools up the active ancestor hierarchy
372
+ const allowedToolsSet = new Set();
373
+ for (let i = 1; i <= this.activeStatePath.length; i++) {
374
+ const def = this.getStateDefinition(this.activeStatePath.slice(0, i));
375
+ if (def?.tools) {
376
+ for (const tool of def.tools) {
377
+ allowedToolsSet.add(tool);
378
+ }
379
+ }
380
+ }
381
+ const allowedTools = Array.from(allowedToolsSet);
382
+ // Aggregate available exit transitions
383
+ const exitConditions = [];
384
+ for (let i = this.activeStatePath.length; i >= 1; i--) {
385
+ const depthPath = this.activeStatePath.slice(0, i);
386
+ const def = this.getStateDefinition(depthPath);
387
+ if (def?.transitions) {
388
+ for (const [signal, trans] of Object.entries(def.transitions)) {
389
+ const transDef = typeof trans === 'string' ? { target: trans } : trans;
390
+ exitConditions.push(`[${depthPath.join('.')}] On signal '${signal}' -> transition to '${transDef.target}'${transDef.guard ? ` (guard: ${transDef.guard})` : ''}`);
391
+ }
392
+ }
393
+ }
394
+ const humanGate = activeLeaf.human_gate;
395
+ let humanGateXml = '';
396
+ if (humanGate) {
397
+ humanGateXml = [
398
+ ` <human_gate type="${humanGate.type}" tool="${humanGate.tool || 'ask_question'}">`,
399
+ ` <instruction>This state requires human input/approval. Use ${humanGate.tool || 'ask_question'} or the review surface to collect user decision, then STOP calling tools to conclude your turn.</instruction>`,
400
+ humanGate.options ? ` <options>${humanGate.options.join(' | ')}</options>` : '',
401
+ ` </human_gate>`,
402
+ ].filter(Boolean).join('\n');
403
+ }
404
+ const formattedXml = [
405
+ `<reactive_skill_state name="${this.getCurrentState()}" path="${this.activeStatePath.join('/')}" skill="${this.manifest.name}">`,
406
+ this.strictExecution ? ' <strict_execution mode="enforced">' : '',
407
+ ' <contract>TODO Card: Load -> Execute -> Emit</contract>',
408
+ ' <contract>Every agent turn must produce a signal via reactive_emit_signal. Fetching state without emitting a signal counts against the idle budget.</contract>',
409
+ ` <contract>Max idle turns: ${this.getStateDefinition(this.activeStatePath)?.max_idle_turns ?? 2}. Exceeding this triggers auto-abort to BYPASS_DETECTED.</contract>`,
410
+ ' <contract>Allowed tools are enforced. Tools outside the allowed list will trigger bypass detection in interceptor mode.</contract>',
411
+ ' <contract>Recovery: reactive-skills-axi reset <skill> then re-invoke.</contract>',
412
+ ' </strict_execution>',
413
+ ` <state_goal>`,
414
+ rawPrompt.trim().split('\n').map(line => ` ${line}`).join('\n'),
415
+ ` </state_goal>`,
416
+ ` <allowed_tools>`,
417
+ allowedTools.map(t => ` <tool name="${t}" />`).join('\n'),
418
+ ` </allowed_tools>`,
419
+ humanGateXml,
420
+ ` <transition_contracts>`,
421
+ exitConditions.map(c => ` <contract>${c}</contract>`).join('\n'),
422
+ ` </transition_contracts>`,
423
+ `</reactive_skill_state>`,
424
+ ].filter(Boolean).join('\n');
425
+ return {
426
+ state: this.getCurrentState(),
427
+ rawPrompt,
428
+ formattedXml,
429
+ allowedTools,
430
+ context: { ...this.context },
431
+ exitConditions,
432
+ };
433
+ }
434
+ /**
435
+ * Check if current state has a blocking Human-in-the-Loop gate
436
+ */
437
+ isWaitingForHuman() {
438
+ const activeLeaf = this.getStateDefinition(this.activeStatePath);
439
+ return Boolean(activeLeaf?.human_gate);
440
+ }
441
+ /**
442
+ * Process an incoming signal event, evaluating transitions with HSM bubbling and draining queued signals
443
+ */
444
+ async handleSignal(signalName, payload = {}, metadata = {}) {
445
+ const previousState = this.getCurrentState();
446
+ const prevPath = [...this.activeStatePath];
447
+ const event = this.eventStore.append('SIGNAL_EMITTED', { signal: signalName, ...payload }, { source: metadata.source, causationId: metadata.causationId, state: previousState });
448
+ this.turnsSinceLastSignal = 0;
449
+ // Merge incoming payload fields that match context_keys into the context
450
+ if (this.manifest.context_keys) {
451
+ const incomingContextUpdates = {};
452
+ for (const key of this.manifest.context_keys) {
453
+ if (payload[key] !== undefined) {
454
+ incomingContextUpdates[key] = payload[key];
455
+ }
456
+ }
457
+ if (Object.keys(incomingContextUpdates).length > 0) {
458
+ this.updateContext(incomingContextUpdates);
459
+ }
460
+ }
461
+ // Bubble search: test from deepest leaf substate up to root
462
+ for (let depth = this.activeStatePath.length; depth >= 1; depth--) {
463
+ const testPath = this.activeStatePath.slice(0, depth);
464
+ const stateDef = this.getStateDefinition(testPath);
465
+ if (stateDef?.transitions && stateDef.transitions[signalName]) {
466
+ const transRaw = stateDef.transitions[signalName];
467
+ const transDef = typeof transRaw === 'string' ? { target: transRaw } : transRaw;
468
+ if (depth < this.activeStatePath.length) {
469
+ this.eventStore.append('EVENT_BUBBLED', {
470
+ signal: signalName,
471
+ fromLeaf: previousState,
472
+ handledAt: testPath.join('.'),
473
+ depth,
474
+ }, { state: previousState, causationId: event.id });
475
+ }
476
+ // Evaluate Guard
477
+ const guardResult = await GuardEvaluator.evaluate(transDef.guard, transDef.guardFunction, {
478
+ event,
479
+ context: this.context,
480
+ currentState: testPath.join('.'),
481
+ skillDir: this.skillDir,
482
+ });
483
+ this.eventStore.append('GUARD_EVALUATED', {
484
+ guard: transDef.guard || transDef.guardFunction || 'true',
485
+ passed: guardResult.passed,
486
+ target: transDef.target,
487
+ handledAt: testPath.join('.'),
488
+ error: guardResult.error,
489
+ }, { state: testPath.join('.'), causationId: event.id });
490
+ if (guardResult.passed) {
491
+ // Auto-invoke child skill if transition declares it
492
+ if (transDef.invoke) {
493
+ await this.invokeSkill(transDef.invoke);
494
+ }
495
+ // Parse target path (supports dot notation, e.g. "REFACTOR.EXTRACT_METHOD" or "GREEN_CODE")
496
+ const targetSegments = transDef.target.split('.');
497
+ const fullTargetPath = this.resolveInitialPath(targetSegments);
498
+ // Execute exit hooks and entry hooks along the transition path
499
+ this.transitionBetweenPaths(prevPath, fullTargetPath, signalName, event.id, payload);
500
+ // PERF-02 / INV-08: Persist state snapshot for fast cold-boot rehydration
501
+ this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
502
+ // Render deliverables
503
+ const deliverablesWritten = this.projectionEngine.project(this.eventStore, this.getCurrentState(), this.manifest.name, this.context, signalName);
504
+ // Drain queued lifecycle signals with cycle-depth guard (REL-02)
505
+ if (!this.isProcessingQueue && this.signalQueue.length > 0) {
506
+ this.isProcessingQueue = true;
507
+ this.signalQueueDepth = 0;
508
+ try {
509
+ while (this.signalQueue.length > 0) {
510
+ this.signalQueueDepth += 1;
511
+ if (this.signalQueueDepth > MAX_QUEUE_DRAIN_DEPTH) {
512
+ const cycleError = new RangeError(`Signal queue cycle detected: exceeded ${MAX_QUEUE_DRAIN_DEPTH} drain steps. ` +
513
+ `Check on_enter/on_exit lifecycle hooks for directed cycles in skill '${this.manifest.name}'.`);
514
+ this.eventStore.append('SIGNAL_QUEUE_CYCLE_DETECTED', {
515
+ depth: this.signalQueueDepth,
516
+ skill: this.manifest.name,
517
+ pendingSignals: this.signalQueue.map(s => s.signal),
518
+ });
519
+ this.signalQueue = [];
520
+ throw cycleError;
521
+ }
522
+ const next = this.signalQueue.shift();
523
+ await this.handleSignal(next.signal, next.payload || {}, next.metadata || {});
524
+ }
525
+ }
526
+ finally {
527
+ this.isProcessingQueue = false;
528
+ this.signalQueueDepth = 0;
529
+ }
530
+ }
531
+ return {
532
+ transitioned: true,
533
+ previousState,
534
+ newState: this.getCurrentState(),
535
+ event,
536
+ handledAtDepth: depth,
537
+ deliverablesWritten,
538
+ };
539
+ }
540
+ }
541
+ }
542
+ return {
543
+ transitioned: false,
544
+ previousState,
545
+ newState: previousState,
546
+ event,
547
+ deliverablesWritten: [],
548
+ };
549
+ }
550
+ /**
551
+ * Transition between two hierarchical paths executing exit, transition, and entry hooks
552
+ */
553
+ transitionBetweenPaths(fromPath, toPath, signalName, causationId, payload) {
554
+ // 1. Find Lowest Common Ancestor (LCA)
555
+ let lcaDepth = 0;
556
+ const maxDepth = Math.min(fromPath.length, toPath.length);
557
+ while (lcaDepth < maxDepth && fromPath[lcaDepth] === toPath[lcaDepth]) {
558
+ lcaDepth++;
559
+ }
560
+ // 2. Exit states from leaf up to LCA (exclusive)
561
+ for (let i = fromPath.length; i > lcaDepth; i--) {
562
+ const exitSubpath = fromPath.slice(0, i);
563
+ this.executeExitHook(exitSubpath, signalName);
564
+ }
565
+ // 3. Record STATE_TRANSITION event in EventStore
566
+ this.eventStore.append('STATE_TRANSITION', {
567
+ from: fromPath.join('.'),
568
+ to: toPath.join('.'),
569
+ signal: signalName,
570
+ payload,
571
+ }, {
572
+ state: toPath.join('.'),
573
+ causationId,
574
+ });
575
+ // 4. Update active state path and context updates
576
+ this.activeStatePath = [...toPath];
577
+ if (payload?.contextUpdates) {
578
+ this.updateContext(payload.contextUpdates);
579
+ }
580
+ // 5. Enter states from LCA (exclusive) down to leaf
581
+ this.enterPath(toPath, fromPath.slice(0, lcaDepth), signalName);
582
+ }
583
+ /**
584
+ * Enter a state hierarchy starting below the common ancestor
585
+ */
586
+ enterPath(targetPath, ancestorPath, triggerSignal) {
587
+ for (let i = ancestorPath.length + 1; i <= targetPath.length; i++) {
588
+ const enterSubpath = targetPath.slice(0, i);
589
+ this.executeEntryHook(enterSubpath, triggerSignal);
590
+ }
591
+ }
592
+ executeEntryHook(statePath, triggerSignal) {
593
+ const def = this.getStateDefinition(statePath);
594
+ if (!def)
595
+ return;
596
+ if (def.human_gate) {
597
+ this.eventStore.append('HUMAN_GATE_ENTERED', {
598
+ state: statePath.join('.'),
599
+ gateType: def.human_gate.type,
600
+ tool: def.human_gate.tool || 'ask_question',
601
+ options: def.human_gate.options,
602
+ }, { state: statePath.join('.') });
603
+ }
604
+ if (def.on_enter) {
605
+ for (const action of def.on_enter) {
606
+ if (action.set_context) {
607
+ this.updateContext(action.set_context);
608
+ }
609
+ if (action.emit_signal) {
610
+ this.eventStore.append('STATE_ENTRY_HOOK', {
611
+ state: statePath.join('.'),
612
+ signalEmitted: action.emit_signal,
613
+ action: action.action,
614
+ }, { state: statePath.join('.') });
615
+ // Queue signal to execute transition cleanly after current cycle
616
+ this.signalQueue.push({
617
+ signal: action.emit_signal,
618
+ payload: {},
619
+ metadata: { source: `on_enter:${statePath.join('.')}` },
620
+ });
621
+ }
622
+ }
623
+ }
624
+ }
625
+ executeExitHook(statePath, triggerSignal) {
626
+ const def = this.getStateDefinition(statePath);
627
+ if (!def || !def.on_exit)
628
+ return;
629
+ for (const action of def.on_exit) {
630
+ if (action.set_context) {
631
+ this.updateContext(action.set_context);
632
+ }
633
+ if (action.emit_signal) {
634
+ this.eventStore.append('STATE_EXIT_HOOK', {
635
+ state: statePath.join('.'),
636
+ signalEmitted: action.emit_signal,
637
+ action: action.action,
638
+ }, { state: statePath.join('.') });
639
+ // Queue signal
640
+ this.signalQueue.push({
641
+ signal: action.emit_signal,
642
+ payload: {},
643
+ metadata: { source: `on_exit:${statePath.join('.')}` },
644
+ });
645
+ }
646
+ }
647
+ }
648
+ }
@@ -0,0 +1,19 @@
1
+ import { SignalEvent } from './types.js';
2
+ export interface GuardEvaluationContext {
3
+ event: SignalEvent;
4
+ context: Record<string, any>;
5
+ currentState: string;
6
+ skillDir?: string;
7
+ }
8
+ /**
9
+ * Guard Evaluator: Safely checks transition guards and domain invariants inside an isolated sandbox
10
+ */
11
+ export declare class GuardEvaluator {
12
+ /**
13
+ * Evaluate a transition guard expression or custom JS function file
14
+ */
15
+ static evaluate(guardExpr: string | undefined, guardFunctionPath: string | undefined, evalContext: GuardEvaluationContext): Promise<{
16
+ passed: boolean;
17
+ error?: string;
18
+ }>;
19
+ }