@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,55 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Zod Schema for validation of skill.yaml
4
+ */
5
+ export const TransitionSchema = z.union([
6
+ z.string(),
7
+ z.object({
8
+ target: z.string(),
9
+ guard: z.string().optional(),
10
+ guardFunction: z.string().optional(),
11
+ description: z.string().optional(),
12
+ invoke: z.string().optional(),
13
+ }),
14
+ ]);
15
+ export const StateLifecycleActionSchema = z.object({
16
+ emit_signal: z.string().optional(),
17
+ set_context: z.record(z.any()).optional(),
18
+ action: z.string().optional(),
19
+ });
20
+ export const HumanGateSchema = z.object({
21
+ type: z.enum(['approval', 'choice', 'text', 'visual_review', 'form']),
22
+ tool: z.string().optional(),
23
+ prompt: z.string().optional(),
24
+ options: z.array(z.string()).optional(),
25
+ auto_stop: z.boolean().optional(),
26
+ });
27
+ export const StateSchema = z.lazy(() => z.object({
28
+ description: z.string().optional(),
29
+ prompt_template: z.string().optional(),
30
+ tools: z.array(z.string()).optional(),
31
+ human_gate: HumanGateSchema.optional(),
32
+ on_enter: z.array(StateLifecycleActionSchema).optional(),
33
+ on_exit: z.array(StateLifecycleActionSchema).optional(),
34
+ transitions: z.record(TransitionSchema).optional(),
35
+ substates: z.record(StateSchema).optional(),
36
+ initial_substate: z.string().optional(),
37
+ max_idle_turns: z.number().int().positive().optional(),
38
+ bypass_target: z.string().optional(),
39
+ }));
40
+ export const SkillManifestSchema = z.object({
41
+ schema_version: z.string(),
42
+ name: z.string(),
43
+ version: z.string().optional(),
44
+ description: z.string(),
45
+ initial_state: z.string(),
46
+ strict_execution: z.boolean().optional(),
47
+ context_keys: z.array(z.string()).optional(),
48
+ default_context: z.record(z.any()).optional(),
49
+ states: z.record(StateSchema),
50
+ deliverable_projections: z.array(z.object({
51
+ template: z.string(),
52
+ output: z.string(),
53
+ trigger_on: z.array(z.string()).optional(),
54
+ })).optional(),
55
+ });
@@ -0,0 +1,12 @@
1
+ export * from './core/types.js';
2
+ export * from './core/event-store.js';
3
+ export * from './core/guard-evaluator.js';
4
+ export * from './core/projection-engine.js';
5
+ export * from './core/fsm-engine.js';
6
+ export * from './core/runtime-hooks.js';
7
+ export * from './core/legacy-adapter.js';
8
+ export * from './core/migration.js';
9
+ export * from './mcp/server.js';
10
+ export * from './sync/types.js';
11
+ export { runSync } from './sync/engine.js';
12
+ export { syncEngineCommand } from './sync/cli.js';
package/dist/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export * from './core/types.js';
2
+ export * from './core/event-store.js';
3
+ export * from './core/guard-evaluator.js';
4
+ export * from './core/projection-engine.js';
5
+ export * from './core/fsm-engine.js';
6
+ export * from './core/runtime-hooks.js';
7
+ export * from './core/legacy-adapter.js';
8
+ export * from './core/migration.js';
9
+ export * from './mcp/server.js';
10
+ export * from './sync/types.js';
11
+ export { runSync } from './sync/engine.js';
12
+ export { syncEngineCommand } from './sync/cli.js';
@@ -0,0 +1,7 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ export interface ReactiveMcpServerOptions {
3
+ workspaceDir?: string;
4
+ defaultSkill?: string;
5
+ }
6
+ export declare function createReactiveMcpServer(options?: ReactiveMcpServerOptions): McpServer;
7
+ export declare function runMcpServer(options?: ReactiveMcpServerOptions): Promise<void>;
@@ -0,0 +1,453 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import yaml from 'js-yaml';
5
+ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
6
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
7
+ import { z } from 'zod';
8
+ import { FSMEngine } from '../core/fsm-engine.js';
9
+ import { EventStore } from '../core/event-store.js';
10
+ import { SkillManifestSchema } from '../core/types.js';
11
+ export function createReactiveMcpServer(options = {}) {
12
+ const workspaceDir = options.workspaceDir || process.cwd();
13
+ let defaultSkill = options.defaultSkill || 'synthesis';
14
+ const server = new McpServer({
15
+ name: 'reactive-skills-server',
16
+ version: '1.0.0',
17
+ });
18
+ // Cached active engine instance per skill
19
+ const engines = new Map();
20
+ function normalizeDeliverableName(name) {
21
+ const trimmed = String(name || '').trim();
22
+ if (!trimmed || trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
23
+ return null;
24
+ }
25
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) {
26
+ return null;
27
+ }
28
+ return trimmed;
29
+ }
30
+ function getEngine(skillName = defaultSkill) {
31
+ if (engines.has(skillName)) {
32
+ return engines.get(skillName);
33
+ }
34
+ const candidatePaths = [
35
+ path.resolve(workspaceDir, 'skills', skillName),
36
+ path.resolve(workspaceDir, skillName),
37
+ path.join(os.homedir(), '.agents', 'skills', skillName),
38
+ path.join(os.homedir(), '.gemini', 'config', 'skills', skillName),
39
+ ];
40
+ const skillDir = candidatePaths.find(p => fs.existsSync(p));
41
+ if (!skillDir) {
42
+ throw new Error(`Skill '${skillName}' not found in workspace or global registry.`);
43
+ }
44
+ const eventStore = new EventStore({
45
+ workspaceDir,
46
+ skillId: skillName,
47
+ enableSqlite: true,
48
+ });
49
+ const engine = new FSMEngine({ skillDir, workspaceDir, eventStore });
50
+ engines.set(skillName, engine);
51
+ return engine;
52
+ }
53
+ // 1. TOOL: reactive_state
54
+ server.tool('reactive_state', 'Get current state, prompt slice, and allowed tools for the active reactive skill', {
55
+ skill: z.string().optional().describe('Skill name (defaults to active skill)'),
56
+ }, async ({ skill }) => {
57
+ try {
58
+ const engine = getEngine(skill || defaultSkill);
59
+ if (engine.isBypassDetected()) {
60
+ return {
61
+ content: [
62
+ {
63
+ type: 'text',
64
+ text: JSON.stringify({
65
+ error: 'BYPASS_DETECTED: The runtime has auto-aborted this skill. Agent exceeded idle turns without emitting a signal.',
66
+ recovery: 'Run reactive-skills-axi reset ' + engine.getManifest().name + ' then re-invoke.',
67
+ strict_execution: engine.isStrictExecution(),
68
+ turns_since_last_signal: engine.getTurnsSinceLastSignal(),
69
+ }, null, 2),
70
+ },
71
+ ],
72
+ isError: true,
73
+ };
74
+ }
75
+ if (engine.isStrictExecution()) {
76
+ engine.recordTurnStart();
77
+ }
78
+ const slice = engine.generatePromptSlice();
79
+ const activeState = engine.getCurrentState();
80
+ const isWaiting = engine.isWaitingForHuman();
81
+ return {
82
+ content: [
83
+ {
84
+ type: 'text',
85
+ text: JSON.stringify({
86
+ skill: engine.getManifest().name,
87
+ activeState,
88
+ isWaitingForHuman: isWaiting,
89
+ allowedTools: slice.allowedTools,
90
+ promptSlice: slice.rawPrompt,
91
+ formattedXml: slice.formattedXml,
92
+ context: engine.getContext(),
93
+ strict_execution: engine.isStrictExecution(),
94
+ turns_since_last_signal: engine.getTurnsSinceLastSignal(),
95
+ }, null, 2),
96
+ },
97
+ ],
98
+ };
99
+ }
100
+ catch (err) {
101
+ if (err.message?.startsWith('BYPASS_DETECTED')) {
102
+ return {
103
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message, recovery: 'Run reactive-skills-axi reset ' + (err.message.split('reset ')[1]?.split(' ')[0] || 'skill') + ' then re-invoke.' }) }],
104
+ isError: true,
105
+ };
106
+ }
107
+ return {
108
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
109
+ isError: true,
110
+ };
111
+ }
112
+ });
113
+ // 2. TOOL: reactive_emit_signal
114
+ server.tool('reactive_emit_signal', 'Emit an event signal into the reactive bus, evaluating transition guards and updating deliverable projections', {
115
+ signal: z.string().describe('Signal name (e.g. CHECK_PASSED, CHARTER_DRAFTED)'),
116
+ payload: z.record(z.any()).optional().describe('Signal payload data (e.g. exit_code, file_path)'),
117
+ skill: z.string().optional().describe('Target skill name'),
118
+ }, async ({ signal, payload = {}, skill }) => {
119
+ try {
120
+ const engine = getEngine(skill || defaultSkill);
121
+ const result = await engine.handleSignal(signal, payload);
122
+ return {
123
+ content: [
124
+ {
125
+ type: 'text',
126
+ text: JSON.stringify({
127
+ transitioned: result.transitioned,
128
+ previousState: result.previousState,
129
+ newState: result.newState,
130
+ isWaitingForHuman: engine.isWaitingForHuman(),
131
+ projectionsWritten: result.deliverablesWritten,
132
+ }, null, 2),
133
+ },
134
+ ],
135
+ };
136
+ }
137
+ catch (err) {
138
+ return {
139
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
140
+ isError: true,
141
+ };
142
+ }
143
+ });
144
+ // 3. TOOL: reactive_query (SQL on events.db)
145
+ server.tool('reactive_query', 'Execute a read-only SQL query against the SQLite event store (.reactive/events.db)', {
146
+ sql: z.string().describe('SQL query string (e.g. SELECT * FROM events ORDER BY seq DESC LIMIT 10)'),
147
+ skill: z.string().optional().describe('Skill context for event store'),
148
+ }, async ({ sql, skill }) => {
149
+ try {
150
+ const engine = getEngine(skill || defaultSkill);
151
+ const driver = engine.getEventStore().getSqliteDriver();
152
+ if (!driver) {
153
+ throw new Error('SQLite storage driver is not active.');
154
+ }
155
+ const rows = driver.querySql(sql);
156
+ return {
157
+ content: [
158
+ {
159
+ type: 'text',
160
+ text: JSON.stringify(rows, null, 2),
161
+ },
162
+ ],
163
+ };
164
+ }
165
+ catch (err) {
166
+ return {
167
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
168
+ isError: true,
169
+ };
170
+ }
171
+ });
172
+ // Typed query surface for normal event inspection; raw SQL remains a legacy read-only escape hatch.
173
+ server.tool('reactive_query_events', 'Query events with bounded typed filters from the selected skill store', {
174
+ type: z.string().optional().describe('Event type filter'),
175
+ state: z.string().optional().describe('State filter'),
176
+ sinceSeq: z.number().int().nonnegative().optional().describe('Return events after this sequence'),
177
+ limit: z.number().int().positive().max(1000).optional().describe('Maximum number of events'),
178
+ skill: z.string().optional().describe('Skill context for event store'),
179
+ }, async ({ type, state, sinceSeq, limit, skill }) => {
180
+ try {
181
+ const engine = getEngine(skill || defaultSkill);
182
+ const rows = engine.getEventStore().query({ type, state, sinceSeq, limit });
183
+ return {
184
+ content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
185
+ };
186
+ }
187
+ catch (err) {
188
+ return {
189
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
190
+ isError: true,
191
+ };
192
+ }
193
+ });
194
+ // 4. TOOL: reactive_list_skills
195
+ server.tool('reactive_list_skills', 'List all available reactive skills in the workspace and global registry', {}, async () => {
196
+ const skillsFound = [];
197
+ const searchDirs = [
198
+ path.resolve(workspaceDir, 'skills'),
199
+ path.join(os.homedir(), '.agents', 'skills'),
200
+ path.join(os.homedir(), '.gemini', 'config', 'skills'),
201
+ ];
202
+ const seen = new Set();
203
+ for (const dir of searchDirs) {
204
+ if (!fs.existsSync(dir))
205
+ continue;
206
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
207
+ for (const entry of entries) {
208
+ if (!entry.isDirectory() || seen.has(entry.name))
209
+ continue;
210
+ const skillYamlPath = path.join(dir, entry.name, 'skill.yaml');
211
+ if (fs.existsSync(skillYamlPath)) {
212
+ try {
213
+ const rawManifest = yaml.load(fs.readFileSync(skillYamlPath, 'utf8'));
214
+ const manifest = SkillManifestSchema.parse(rawManifest);
215
+ skillsFound.push({
216
+ name: manifest.name,
217
+ path: path.join(dir, entry.name),
218
+ type: 'reactive',
219
+ statesCount: Object.keys(manifest.states).length,
220
+ });
221
+ seen.add(entry.name);
222
+ }
223
+ catch {
224
+ // Ignore invalid manifests
225
+ }
226
+ }
227
+ }
228
+ }
229
+ return {
230
+ content: [
231
+ {
232
+ type: 'text',
233
+ text: JSON.stringify(skillsFound, null, 2),
234
+ },
235
+ ],
236
+ };
237
+ });
238
+ // 5. TOOL: reactive_inspect
239
+ server.tool('reactive_inspect', 'Inspect the full statechart, transitions, and guard criteria of a reactive skill', {
240
+ skill: z.string().optional().describe('Skill name to inspect'),
241
+ }, async ({ skill }) => {
242
+ try {
243
+ const engine = getEngine(skill || defaultSkill);
244
+ const manifest = engine.getManifest();
245
+ return {
246
+ content: [
247
+ {
248
+ type: 'text',
249
+ text: JSON.stringify(manifest, null, 2),
250
+ },
251
+ ],
252
+ };
253
+ }
254
+ catch (err) {
255
+ return {
256
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
257
+ isError: true,
258
+ };
259
+ }
260
+ });
261
+ // 6. TOOL: reactive_invoke_skill
262
+ server.tool('reactive_invoke_skill', 'Invoke a child reactive skill or legacy SKILL.md, recording parent/child event provenance', {
263
+ skill: z.string().describe('Skill name or path to invoke'),
264
+ }, async ({ skill }) => {
265
+ try {
266
+ const engine = getEngine();
267
+ const result = await engine.invokeSkill(skill);
268
+ return {
269
+ content: [
270
+ {
271
+ type: 'text',
272
+ text: JSON.stringify({
273
+ invoked: true,
274
+ skillId: result.skillId,
275
+ currentState: result.currentState,
276
+ promptSlice: result.promptSlice.rawPrompt,
277
+ allowedTools: result.promptSlice.allowedTools,
278
+ exitConditions: result.promptSlice.exitConditions,
279
+ eventId: result.event.id,
280
+ }, null, 2),
281
+ },
282
+ ],
283
+ };
284
+ }
285
+ catch (err) {
286
+ return {
287
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
288
+ isError: true,
289
+ };
290
+ }
291
+ });
292
+ // 6. TOOL: reactive_respond_human
293
+ server.tool('reactive_respond_human', 'Submit user approval or feedback to unpause a Human-in-the-Loop (HITL) gate', {
294
+ choice: z.string().describe('User selected choice (e.g. Approve Plan)'),
295
+ approved: z.boolean().optional().describe('Explicit approval boolean flag'),
296
+ feedback: z.string().optional().describe('Optional feedback text'),
297
+ skill: z.string().optional().describe('Target skill name'),
298
+ }, async ({ choice, approved = true, feedback, skill }) => {
299
+ try {
300
+ const engine = getEngine(skill || defaultSkill);
301
+ const signalsEmitted = [];
302
+ let transitioned = false;
303
+ let deliverablesWritten = [];
304
+ if (feedback) {
305
+ engine.updateContext({ user_feedback: feedback });
306
+ }
307
+ if (choice) {
308
+ engine.updateContext({ user_choice: choice });
309
+ }
310
+ if (choice || feedback) {
311
+ engine.recordDecision({
312
+ choice: choice || '',
313
+ feedback,
314
+ approved,
315
+ });
316
+ }
317
+ const dispatch = async (signalName, payload) => {
318
+ signalsEmitted.push(signalName);
319
+ const res = await engine.handleSignal(signalName, payload, { source: 'human_ingress' });
320
+ if (res.transitioned)
321
+ transitioned = true;
322
+ if (res.deliverablesWritten.length > 0)
323
+ deliverablesWritten.push(...res.deliverablesWritten);
324
+ };
325
+ const responseData = { choice, approved, feedback };
326
+ await dispatch('USER_RESPONSE', responseData);
327
+ if (approved === true || (choice && /approve|yes|accept|confirm|proceed/i.test(choice))) {
328
+ await dispatch('USER_APPROVED', responseData);
329
+ }
330
+ else if (approved === false || (choice && /reject|no|abort|cancel/i.test(choice))) {
331
+ await dispatch('USER_REJECTED', responseData);
332
+ }
333
+ else if (choice && /revision|change|modify|fix/i.test(choice)) {
334
+ await dispatch('USER_REVISION_REQUESTED', responseData);
335
+ }
336
+ return {
337
+ content: [
338
+ {
339
+ type: 'text',
340
+ text: JSON.stringify({
341
+ unpaused: true,
342
+ transitioned,
343
+ currentState: engine.getCurrentState(),
344
+ projectionsWritten: deliverablesWritten,
345
+ signalsEmitted,
346
+ }, null, 2),
347
+ },
348
+ ],
349
+ };
350
+ }
351
+ catch (err) {
352
+ return {
353
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
354
+ isError: true,
355
+ };
356
+ }
357
+ });
358
+ // 7. TOOL: reactive_migrate
359
+ server.tool('reactive_migrate', 'Retroactively upgrade an existing project workspace to the latest Event Modeling and CQRS schema standards', {
360
+ targetDir: z.string().optional().describe('Project directory path (defaults to current workspace)'),
361
+ }, async ({ targetDir }) => {
362
+ try {
363
+ const resolved = targetDir ? path.resolve(workspaceDir, targetDir) : workspaceDir;
364
+ const relative = path.relative(workspaceDir, resolved);
365
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
366
+ return {
367
+ content: [{ type: 'text', text: JSON.stringify({ error: `targetDir escapes workspace directory: ${targetDir}` }) }],
368
+ isError: true,
369
+ };
370
+ }
371
+ if (fs.existsSync(resolved)) {
372
+ const realWorkspace = fs.existsSync(workspaceDir) ? fs.realpathSync(workspaceDir) : workspaceDir;
373
+ const realResolved = fs.realpathSync(resolved);
374
+ const realRel = path.relative(realWorkspace, realResolved);
375
+ if (realRel.startsWith('..') || path.isAbsolute(realRel)) {
376
+ return {
377
+ content: [{ type: 'text', text: JSON.stringify({ error: `targetDir escapes workspace directory: ${targetDir}` }) }],
378
+ isError: true,
379
+ };
380
+ }
381
+ }
382
+ const { ProjectMigrator } = await import('../core/migration.js');
383
+ const result = ProjectMigrator.migrate(resolved);
384
+ return {
385
+ content: [
386
+ {
387
+ type: 'text',
388
+ text: JSON.stringify(result, null, 2),
389
+ },
390
+ ],
391
+ };
392
+ }
393
+ catch (err) {
394
+ return {
395
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
396
+ isError: true,
397
+ };
398
+ }
399
+ });
400
+ // RESOURCE 1: reactive://events
401
+ server.resource('reactive-events', 'reactive://events', async (uri) => {
402
+ const store = new EventStore({ enableSqlite: true });
403
+ const events = store.getAll();
404
+ return {
405
+ contents: [
406
+ {
407
+ uri: uri.href,
408
+ text: JSON.stringify(events, null, 2),
409
+ mimeType: 'application/json',
410
+ },
411
+ ],
412
+ };
413
+ });
414
+ // RESOURCE 2: reactive://deliverables/{name}
415
+ server.resource('reactive-deliverable', new ResourceTemplate('reactive://deliverables/{name}', { list: undefined }), async (uri, { name }) => {
416
+ const rawName = Array.isArray(name) ? name[0] : name;
417
+ const safeName = normalizeDeliverableName(rawName || '');
418
+ if (!safeName) {
419
+ return {
420
+ contents: [
421
+ {
422
+ uri: uri.href,
423
+ text: '# Invalid deliverable name',
424
+ mimeType: 'text/markdown',
425
+ },
426
+ ],
427
+ };
428
+ }
429
+ const docCandidates = [
430
+ path.resolve(workspaceDir, '.docs', 'synthesis', `${safeName}.md`),
431
+ path.resolve(workspaceDir, '.docs', `${safeName}.md`),
432
+ path.resolve(workspaceDir, `${safeName}.md`),
433
+ ];
434
+ const docPath = docCandidates.find(p => fs.existsSync(p));
435
+ const content = docPath ? fs.readFileSync(docPath, 'utf8') : `# Deliverable ${safeName} Not Found`;
436
+ return {
437
+ contents: [
438
+ {
439
+ uri: uri.href,
440
+ text: content,
441
+ mimeType: 'text/markdown',
442
+ },
443
+ ],
444
+ };
445
+ });
446
+ return server;
447
+ }
448
+ export async function runMcpServer(options = {}) {
449
+ const server = createReactiveMcpServer(options);
450
+ const transport = new StdioServerTransport();
451
+ await server.connect(transport);
452
+ // Stdio transport handles process.stdin/stdout directly
453
+ }
@@ -0,0 +1 @@
1
+ export declare function syncEngineCommand(args: string[]): Promise<string>;