@reactive-skills/runtime 0.2.0 โ†’ 0.4.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,45 @@
1
+
2
+ ## [0.4.0] - 2026-09-14
3
+
4
+ - feat(axi): implement native validate command for skill manifest and statechart verification (3b75a21)
5
+ - docs: add v0.3.0 release highlights to READMEs and bundle changelog in package files (732f72b)
6
+ - chore: release v0.3.0 (ae379ca)
7
+ - feat(core): implement job management, isolated run storage, dual-write projections, and AXI jobs suite (c76c6cb)
8
+ - chore: release v0.2.0 (bb33cfc)
9
+
10
+ ## [0.3.0] - 2026-09-13
11
+
12
+ - feat(core): implement job management, isolated run storage, dual-write projections, and AXI jobs suite (c76c6cb)
13
+
14
+ ## [2.0.0] - 2026-09-01
15
+
16
+ - feat: implement SQLite event store engine, state snapshotting, and JSONL log rotation with supporting documentation (6912493)
17
+ - feat: implement skill-scoped SQLite event storage and migration infrastructure (b28fb19)
18
+ - feat: implement core event-store with SQLite persistence, FSM engine, and projection tracking. (5926118)
19
+ - feat: implement reactive skill core with FSM engine, event-sourced SQLite storage, and Zod-validated manifest schemas (b542c0a)
20
+ - feat: add CLI tool for reactive skill management, project documentation, and state inspection workflows (39830d0)
21
+
22
+ ## [1.0.3] - 2026-08-31
23
+
24
+ - refactor(core): migrate to clean semantic state filenames and convention-over-configuration resolution (8180b1b)
25
+ - feat(hitl): add pre-implementation collaboration gate with 3 interaction modes (direct build, brainstorm, plan review) (0cd22b7)
26
+ - fix(core): resolve P2 memory leak - add bounded in-memory sliding buffer with SQLite backing (7fefae5)
27
+ - fix(core): resolve all Principal Engineer review findings - state rehydration, lifecycle signal queue, VM sandbox, and dual-write durability (791753e)
28
+ - feat(mcp): add reactive_migrate tool to MCP server suite (e35779c)
29
+
30
+ ## [1.0.2] - 2026-08-31
31
+
32
+ - chore(release): add automated multi-file semver bumping script and changelog generator (d0f9abd)
33
+ - feat(docs): add automated doc invariant checker script and synchronize all project docs (280a95d)
34
+ - feat(core): upgrade to v2 with Event Modeling 4-slice CQRS, statechart topology validation, and retroactive migration (c9801ed)
35
+ - docs: clarify workspace layout, file destination paths, and greenfield/brownfield execution (1c79455)
36
+ - docs: add Model Context Protocol (MCP) server documentation to README, architecture, and AGENTS (f2e638b)
37
+ # Changelog
38
+
39
+ ## [1.0.1] - 2026-08-31
40
+
41
+ - feat(docs): add automated doc invariant checker script and synchronize all project docs (280a95d)
42
+ - feat(core): upgrade to v2 with Event Modeling 4-slice CQRS, statechart topology validation, and retroactive migration (c9801ed)
43
+ - docs: clarify workspace layout, file destination paths, and greenfield/brownfield execution (1c79455)
44
+ - docs: add Model Context Protocol (MCP) server documentation to README, architecture, and AGENTS (f2e638b)
45
+ - feat(mcp): build universal reactive-mcp-server with 6 tools and live resources (9a01807)
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # @reactive-skills/runtime
2
2
 
3
- Reactive Skills Architecture (RSA) core runtime - FSM engine, event store, guard evaluator, projection engine, and MCP server.
3
+ Reactive Skills Architecture (RSA) core runtime โ€” FSM engine, event store, guard evaluator, projection engine, job manager, and MCP server.
4
+
5
+ > ๐Ÿš€ **What's New in v0.4.0:** Native `validate` command in AXI, first-class Job & Run Management, isolated event logs, and dual-write deliverable mirroring. [Read Full Release Notes โ†’](https://github.com/Reactive-Skills/reactive-skills/releases/tag/v0.4.0) ยท [View Changelog](https://github.com/Reactive-Skills/reactive-skills/blob/main/CHANGELOG.md)
4
6
 
5
7
  ## Installation
6
8
 
@@ -11,15 +13,16 @@ npm install @reactive-skills/runtime
11
13
  ## Usage
12
14
 
13
15
  ```ts
14
- import { FSMEngine, EventStore, LegacySkillAdapter, ProjectionEngine } from '@reactive-skills/runtime';
16
+ import { FSMEngine, EventStore, JobManager, LegacySkillAdapter, ProjectionEngine } from '@reactive-skills/runtime';
15
17
  ```
16
18
 
17
19
  ## Core Modules
18
20
 
19
21
  - `FSMEngine` - Hierarchical State Machine loader, state path resolver, and signal-driven transition engine
22
+ - `JobManager` - Run isolation, active pointer resolution, metadata management, and safe directory pathing
20
23
  - `EventStore` - Immutable append-only event ledger (JSONL + SQLite)
21
24
  - `GuardEvaluator` - Sandbox evaluator for transition guard expressions
22
- - `ProjectionEngine` - Handlebars deliverable generator for read-model projections
25
+ - `ProjectionEngine` - Handlebars deliverable generator for read-model projections with dual-write archiving
23
26
  - `LegacySkillAdapter` - Backward compatibility wrapper and converter for SKILL.md
24
27
  - `McpServer` - Stdio Model Context Protocol (MCP) server integration
25
28
  - `TelemetryServer` - Real-time Server-Sent Events (SSE) broadcaster and Private Network Access (PNA) HTTP bridge
@@ -7,6 +7,7 @@ export interface EventStoreOptions {
7
7
  sqlitePath?: string;
8
8
  workspaceDir?: string;
9
9
  skillId?: string;
10
+ jobId?: string;
10
11
  runId?: string;
11
12
  run_id?: string;
12
13
  correlationId?: string;
@@ -23,6 +24,7 @@ export interface EventStoreOptions {
23
24
  enableSqlite?: boolean;
24
25
  maxInMemoryEvents?: number;
25
26
  maxJsonlBytes?: number;
27
+ acquireLock?: boolean;
26
28
  }
27
29
  export interface EventQueryOptions {
28
30
  type?: string;
@@ -36,6 +38,7 @@ export interface EventQueryOptions {
36
38
  */
37
39
  export declare class SQLiteStorageDriver {
38
40
  private db;
41
+ private isClosed;
39
42
  constructor(dbPath: string);
40
43
  private initTables;
41
44
  private ensureSchemaVersion;
@@ -93,7 +96,11 @@ export declare class EventStore {
93
96
  private projectionWatermarks;
94
97
  private latestSnapshot;
95
98
  private maxJsonlBytes;
99
+ private lockFd;
100
+ private lockPath;
96
101
  constructor(options?: EventStoreOptions);
102
+ private acquireJobLock;
103
+ private releaseJobLock;
97
104
  private initializeStorage;
98
105
  /**
99
106
  * Scan all JSONL files for the authoritative max seq and event count.
@@ -168,7 +175,7 @@ export declare class EventStore {
168
175
  */
169
176
  query(filter?: EventQueryOptions): SignalEvent[];
170
177
  /**
171
- * Close storage drivers and release file handles
178
+ * Close storage drivers and release file handles and locks
172
179
  */
173
180
  close(): void;
174
181
  /**
@@ -20,6 +20,7 @@ export function createSortableId() {
20
20
  */
21
21
  export class SQLiteStorageDriver {
22
22
  db;
23
+ isClosed = false;
23
24
  constructor(dbPath) {
24
25
  if (dbPath !== ':memory:') {
25
26
  const dir = path.dirname(dbPath);
@@ -237,7 +238,17 @@ export class SQLiteStorageDriver {
237
238
  return row ? row.content : null;
238
239
  }
239
240
  close() {
240
- this.db.close();
241
+ if (this.isClosed)
242
+ return;
243
+ try {
244
+ this.db.close();
245
+ }
246
+ catch {
247
+ // already closed
248
+ }
249
+ finally {
250
+ this.isClosed = true;
251
+ }
241
252
  }
242
253
  static getSchemaVersion(dbPath) {
243
254
  const db = new DatabaseSync(dbPath);
@@ -334,12 +345,15 @@ export class EventStore {
334
345
  projectionWatermarks = new Map();
335
346
  latestSnapshot = null;
336
347
  maxJsonlBytes;
348
+ lockFd = null;
349
+ lockPath = null;
337
350
  constructor(options = {}) {
338
351
  this.maxInMemoryEvents = options.maxInMemoryEvents || 1000;
339
352
  this.maxJsonlBytes = options.maxJsonlBytes || 10 * 1024 * 1024;
353
+ const effectiveJobId = options.jobId || options.runId || options.run_id;
340
354
  this.eventContext = {
341
355
  skill_id: options.skillId,
342
- run_id: options.runId || options.run_id || createSortableId(),
356
+ run_id: effectiveJobId || createSortableId(),
343
357
  correlation_id: options.correlationId || createSortableId(),
344
358
  request_id: options.requestId,
345
359
  trace_parent: options.traceParent,
@@ -351,9 +365,21 @@ export class EventStore {
351
365
  const scopeDir = options.skillId
352
366
  ? path.join(workspaceDir, '.reactive', 'skills', options.skillId)
353
367
  : path.join(workspaceDir, '.reactive');
354
- const runScopedDir = options.runId
355
- ? path.join(scopeDir, options.runId)
356
- : scopeDir;
368
+ let runScopedDir = scopeDir;
369
+ if (options.skillId && effectiveJobId) {
370
+ const isLegacyFallback = effectiveJobId === 'default' &&
371
+ !fs.existsSync(path.join(scopeDir, 'jobs', effectiveJobId)) &&
372
+ fs.existsSync(path.join(scopeDir, 'events.jsonl'));
373
+ if (!isLegacyFallback) {
374
+ runScopedDir = path.join(scopeDir, 'jobs', effectiveJobId);
375
+ }
376
+ }
377
+ else if (effectiveJobId) {
378
+ runScopedDir = path.join(scopeDir, effectiveJobId);
379
+ }
380
+ if (options.acquireLock && runScopedDir) {
381
+ this.acquireJobLock(runScopedDir);
382
+ }
357
383
  this.storagePath = options.storagePath || path.join(runScopedDir, 'events.jsonl');
358
384
  if (options.enableSqlite || options.sqlitePath) {
359
385
  const dbPath = options.sqlitePath || path.join(runScopedDir, 'events.db');
@@ -366,6 +392,69 @@ export class EventStore {
366
392
  this.sqliteDriver = new SQLiteStorageDriver(':memory:');
367
393
  }
368
394
  }
395
+ acquireJobLock(dir) {
396
+ if (!fs.existsSync(dir)) {
397
+ fs.mkdirSync(dir, { recursive: true });
398
+ }
399
+ this.lockPath = path.join(dir, '.lock');
400
+ try {
401
+ this.lockFd = fs.openSync(this.lockPath, 'wx');
402
+ fs.writeSync(this.lockFd, JSON.stringify({ pid: process.pid, time: new Date().toISOString() }));
403
+ }
404
+ catch (err) {
405
+ if (err.code === 'EEXIST') {
406
+ try {
407
+ const content = fs.readFileSync(this.lockPath, 'utf8');
408
+ const data = JSON.parse(content);
409
+ if (data.pid) {
410
+ try {
411
+ process.kill(data.pid, 0);
412
+ const lockError = new Error(`JOB_LOCKED: Job directory is currently locked by PID ${data.pid}`);
413
+ lockError.code = 'JOB_LOCKED';
414
+ throw lockError;
415
+ }
416
+ catch (killErr) {
417
+ if (killErr.code === 'ESRCH') {
418
+ fs.unlinkSync(this.lockPath);
419
+ this.lockFd = fs.openSync(this.lockPath, 'wx');
420
+ fs.writeSync(this.lockFd, JSON.stringify({ pid: process.pid, time: new Date().toISOString() }));
421
+ return;
422
+ }
423
+ throw killErr;
424
+ }
425
+ }
426
+ }
427
+ catch (readErr) {
428
+ if (readErr.code === 'JOB_LOCKED')
429
+ throw readErr;
430
+ }
431
+ const lockError = new Error(`JOB_LOCKED: Job directory is currently locked`);
432
+ lockError.code = 'JOB_LOCKED';
433
+ throw lockError;
434
+ }
435
+ throw err;
436
+ }
437
+ }
438
+ releaseJobLock() {
439
+ if (this.lockFd !== null) {
440
+ try {
441
+ fs.closeSync(this.lockFd);
442
+ }
443
+ catch {
444
+ // ignore
445
+ }
446
+ this.lockFd = null;
447
+ }
448
+ if (this.lockPath && fs.existsSync(this.lockPath)) {
449
+ try {
450
+ fs.unlinkSync(this.lockPath);
451
+ }
452
+ catch {
453
+ // ignore
454
+ }
455
+ this.lockPath = null;
456
+ }
457
+ }
369
458
  initializeStorage() {
370
459
  // SQLite is authoritative. If it already has events, load the recent
371
460
  // sliding window and check JSONL for divergence before returning.
@@ -727,9 +816,10 @@ export class EventStore {
727
816
  return filter.limit ? filtered.slice(0, filter.limit) : filtered;
728
817
  }
729
818
  /**
730
- * Close storage drivers and release file handles
819
+ * Close storage drivers and release file handles and locks
731
820
  */
732
821
  close() {
822
+ this.releaseJobLock();
733
823
  if (this.sqliteDriver) {
734
824
  this.sqliteDriver.close();
735
825
  }
@@ -748,6 +838,7 @@ export class EventStore {
748
838
  this.seqCounter = 0;
749
839
  this.projectionWatermarks.clear();
750
840
  this.latestSnapshot = null;
841
+ this.releaseJobLock();
751
842
  if (this.sqliteDriver) {
752
843
  this.sqliteDriver.clear();
753
844
  }
@@ -1,10 +1,12 @@
1
1
  import { SkillManifest, StateDefinition, SignalEvent, PromptSlice, EventContext, ChildRunSummary, DecisionRecord } from './types.js';
2
2
  import { EventStore } from './event-store.js';
3
+ import { JobManager } from './job-manager.js';
3
4
  export interface FSMEngineOptions {
4
5
  skillDir: string;
5
6
  workspaceDir?: string;
6
7
  eventStore?: EventStore;
7
8
  eventContext?: EventContext;
9
+ jobId?: string;
8
10
  runId?: string;
9
11
  initialContext?: Record<string, any>;
10
12
  autoRehydrate?: boolean;
@@ -23,6 +25,9 @@ export declare class FSMEngine {
23
25
  private turnsSinceLastSignal;
24
26
  private inBypassState;
25
27
  private readonly strictExecution;
28
+ private jobId?;
29
+ private isActiveJob;
30
+ private jobManager;
26
31
  constructor(options: FSMEngineOptions);
27
32
  /**
28
33
  * Rehydrate state machine state and context from immutable event history,
@@ -92,4 +97,8 @@ export declare class FSMEngine {
92
97
  private enterPath;
93
98
  private executeEntryHook;
94
99
  private executeExitHook;
100
+ getJobId(): string | undefined;
101
+ isJobActive(): boolean;
102
+ getJobManager(): JobManager;
103
+ close(): void;
95
104
  }
@@ -7,6 +7,7 @@ import { EventStore, createSortableId } from './event-store.js';
7
7
  import { GuardEvaluator } from './guard-evaluator.js';
8
8
  import { LegacySkillAdapter } from './legacy-adapter.js';
9
9
  import { ProjectionEngine } from './projection-engine.js';
10
+ import { JobManager } from './job-manager.js';
10
11
  /** Maximum lifecycle-signal drain steps per top-level handleSignal call.
11
12
  * Prevents cyclic on_enter/on_exit emissions from overflowing the call stack (REL-02). */
12
13
  const MAX_QUEUE_DRAIN_DEPTH = 50;
@@ -24,6 +25,9 @@ export class FSMEngine {
24
25
  turnsSinceLastSignal;
25
26
  inBypassState;
26
27
  strictExecution;
28
+ jobId;
29
+ isActiveJob;
30
+ jobManager;
27
31
  constructor(options) {
28
32
  this.skillDir = path.resolve(options.skillDir);
29
33
  this.workspaceDir = options.workspaceDir || process.cwd();
@@ -31,10 +35,29 @@ export class FSMEngine {
31
35
  this.strictExecution = this.manifest.strict_execution === true;
32
36
  this.turnsSinceLastSignal = 0;
33
37
  this.inBypassState = false;
38
+ const effectiveJobId = options.jobId || options.runId;
39
+ this.jobManager = new JobManager(this.workspaceDir);
40
+ const activeJobId = this.jobManager.getActiveJobId(this.manifest.name);
41
+ const resolvedJobId = effectiveJobId || activeJobId;
42
+ const isActiveJob = (resolvedJobId === activeJobId);
43
+ this.jobId = resolvedJobId;
44
+ this.isActiveJob = isActiveJob;
45
+ if (this.jobId) {
46
+ const existingJob = this.jobManager.getJob(this.manifest.name, this.jobId);
47
+ if (!existingJob) {
48
+ this.jobManager.createJob(this.manifest.name, {
49
+ id: this.jobId,
50
+ name: this.jobId,
51
+ initialState: this.manifest.initial_state,
52
+ setActive: this.isActiveJob,
53
+ });
54
+ }
55
+ }
34
56
  this.eventStore = options.eventStore || new EventStore({
35
57
  skillId: this.manifest.name,
36
58
  workspaceDir: this.workspaceDir,
37
- runId: options.runId,
59
+ jobId: this.jobId,
60
+ runId: this.jobId,
38
61
  enableSqlite: true,
39
62
  ...options.eventContext,
40
63
  });
@@ -42,7 +65,7 @@ export class FSMEngine {
42
65
  ...(this.manifest.default_context || {}),
43
66
  ...(options.initialContext || {}),
44
67
  };
45
- this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd());
68
+ this.projectionEngine = new ProjectionEngine(this.skillDir, this.manifest.deliverable_projections || [], options.workspaceDir || process.cwd(), this.jobId, this.isActiveJob);
46
69
  const autoRehydrate = options.autoRehydrate !== false;
47
70
  const latestSnapshot = autoRehydrate ? this.eventStore.getLatestSnapshot() : null;
48
71
  const history = autoRehydrate
@@ -50,6 +73,11 @@ export class FSMEngine {
50
73
  : [];
51
74
  if (autoRehydrate && (latestSnapshot || history.length > 0)) {
52
75
  this.rehydrate(history, latestSnapshot);
76
+ if (this.jobId) {
77
+ this.jobManager.updateJob(this.manifest.name, this.jobId, {
78
+ currentState: this.getCurrentState(),
79
+ });
80
+ }
53
81
  }
54
82
  else {
55
83
  const initialPath = this.resolveInitialPath([this.manifest.initial_state]);
@@ -62,6 +90,11 @@ export class FSMEngine {
62
90
  }, { state: initialPath.join('.') });
63
91
  this.enterPath(initialPath, [], 'INITIAL_BOOT');
64
92
  this.eventStore.saveSnapshot(this.eventStore.getLatestSequence(), this.getCurrentState(), this.context);
93
+ if (this.jobId) {
94
+ this.jobManager.updateJob(this.manifest.name, this.jobId, {
95
+ currentState: this.getCurrentState(),
96
+ });
97
+ }
65
98
  }
66
99
  }
67
100
  /**
@@ -531,6 +564,11 @@ export class FSMEngine {
531
564
  this.signalQueueDepth = 0;
532
565
  }
533
566
  }
567
+ if (this.jobId) {
568
+ this.jobManager.updateJob(this.manifest.name, this.jobId, {
569
+ currentState: this.getCurrentState(),
570
+ });
571
+ }
534
572
  return {
535
573
  transitioned: true,
536
574
  previousState,
@@ -648,4 +686,16 @@ export class FSMEngine {
648
686
  }
649
687
  }
650
688
  }
689
+ getJobId() {
690
+ return this.jobId;
691
+ }
692
+ isJobActive() {
693
+ return this.isActiveJob;
694
+ }
695
+ getJobManager() {
696
+ return this.jobManager;
697
+ }
698
+ close() {
699
+ this.eventStore.close();
700
+ }
651
701
  }
@@ -0,0 +1,50 @@
1
+ import { JobMetadata } from './types.js';
2
+ export declare const DEFAULT_JOB_ID = "default";
3
+ /**
4
+ * Converts arbitrary human strings (e.g. 'Auth Slice v1.0') into safe filesystem slugs.
5
+ */
6
+ export declare function normalizeJobSlug(name: string): string;
7
+ export interface CreateJobOptions {
8
+ id?: string;
9
+ name?: string;
10
+ initialState?: string;
11
+ parentRunId?: string;
12
+ setActive?: boolean;
13
+ }
14
+ /**
15
+ * JobManager handles execution run isolation, active job pointer resolution,
16
+ * and job metadata persistence.
17
+ */
18
+ export declare class JobManager {
19
+ private workspaceDir;
20
+ constructor(workspaceDir?: string);
21
+ getSkillDir(skillId: string): string;
22
+ getActivePointerPath(skillId: string): string;
23
+ getJobsDir(skillId: string): string;
24
+ getJobDir(skillId: string, jobId: string): string;
25
+ /**
26
+ * Resolves the current active job ID for a skill.
27
+ * Falls back to DEFAULT_JOB_ID ('default') if no pointer exists.
28
+ */
29
+ getActiveJobId(skillId: string): string;
30
+ /**
31
+ * Sets the active job pointer for a skill.
32
+ */
33
+ setActiveJobId(skillId: string, jobId: string): void;
34
+ /**
35
+ * Creates a new isolated job and persists its job.json metadata.
36
+ */
37
+ createJob(skillId: string, options?: CreateJobOptions): JobMetadata;
38
+ /**
39
+ * Retrieves a job's metadata by ID.
40
+ */
41
+ getJob(skillId: string, jobId: string): JobMetadata | null;
42
+ /**
43
+ * Updates a job's metadata (e.g. status, currentState, completedAt).
44
+ */
45
+ updateJob(skillId: string, jobId: string, updates: Partial<JobMetadata>): JobMetadata;
46
+ /**
47
+ * Lists all jobs for a skill.
48
+ */
49
+ listJobs(skillId: string): JobMetadata[];
50
+ }
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createSortableId } from './event-store.js';
4
+ import { JobMetadataSchema } from './types.js';
5
+ export const DEFAULT_JOB_ID = 'default';
6
+ /**
7
+ * Converts arbitrary human strings (e.g. 'Auth Slice v1.0') into safe filesystem slugs.
8
+ */
9
+ export function normalizeJobSlug(name) {
10
+ const cleaned = name
11
+ .trim()
12
+ .toLowerCase()
13
+ .replace(/[^a-z0-9]+/g, '-')
14
+ .replace(/^-+|-+$/g, '');
15
+ if (!cleaned) {
16
+ return `job-${createSortableId().slice(0, 8)}`;
17
+ }
18
+ return cleaned;
19
+ }
20
+ /**
21
+ * JobManager handles execution run isolation, active job pointer resolution,
22
+ * and job metadata persistence.
23
+ */
24
+ export class JobManager {
25
+ workspaceDir;
26
+ constructor(workspaceDir = process.cwd()) {
27
+ this.workspaceDir = path.resolve(workspaceDir);
28
+ }
29
+ getSkillDir(skillId) {
30
+ return path.join(this.workspaceDir, '.reactive', 'skills', skillId);
31
+ }
32
+ getActivePointerPath(skillId) {
33
+ return path.join(this.getSkillDir(skillId), 'active_job');
34
+ }
35
+ getJobsDir(skillId) {
36
+ return path.join(this.getSkillDir(skillId), 'jobs');
37
+ }
38
+ getJobDir(skillId, jobId) {
39
+ return path.join(this.getJobsDir(skillId), jobId);
40
+ }
41
+ /**
42
+ * Resolves the current active job ID for a skill.
43
+ * Falls back to DEFAULT_JOB_ID ('default') if no pointer exists.
44
+ */
45
+ getActiveJobId(skillId) {
46
+ const pointerFile = this.getActivePointerPath(skillId);
47
+ if (fs.existsSync(pointerFile)) {
48
+ try {
49
+ const id = fs.readFileSync(pointerFile, 'utf8').trim();
50
+ if (id)
51
+ return id;
52
+ }
53
+ catch {
54
+ // Fall back to default
55
+ }
56
+ }
57
+ return DEFAULT_JOB_ID;
58
+ }
59
+ /**
60
+ * Sets the active job pointer for a skill.
61
+ */
62
+ setActiveJobId(skillId, jobId) {
63
+ const targetDir = this.getSkillDir(skillId);
64
+ if (!fs.existsSync(targetDir)) {
65
+ fs.mkdirSync(targetDir, { recursive: true });
66
+ }
67
+ const pointerFile = this.getActivePointerPath(skillId);
68
+ fs.writeFileSync(pointerFile, jobId.trim(), 'utf8');
69
+ }
70
+ /**
71
+ * Creates a new isolated job and persists its job.json metadata.
72
+ */
73
+ createJob(skillId, options = {}) {
74
+ const id = options.id || createSortableId();
75
+ const name = options.name ? normalizeJobSlug(options.name) : (options.id ? normalizeJobSlug(options.id) : DEFAULT_JOB_ID);
76
+ const now = new Date().toISOString();
77
+ const metadata = {
78
+ id,
79
+ name,
80
+ skillId,
81
+ status: 'active',
82
+ currentState: options.initialState || 'INIT',
83
+ parentRunId: options.parentRunId,
84
+ createdAt: now,
85
+ updatedAt: now,
86
+ };
87
+ const jobDir = this.getJobDir(skillId, id);
88
+ if (!fs.existsSync(jobDir)) {
89
+ fs.mkdirSync(jobDir, { recursive: true });
90
+ }
91
+ const metadataPath = path.join(jobDir, 'job.json');
92
+ fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2), 'utf8');
93
+ if (options.setActive) {
94
+ this.setActiveJobId(skillId, id);
95
+ }
96
+ return metadata;
97
+ }
98
+ /**
99
+ * Retrieves a job's metadata by ID.
100
+ */
101
+ getJob(skillId, jobId) {
102
+ const jobDir = this.getJobDir(skillId, jobId);
103
+ const metadataPath = path.join(jobDir, 'job.json');
104
+ if (fs.existsSync(metadataPath)) {
105
+ try {
106
+ const raw = JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
107
+ return JobMetadataSchema.parse(raw);
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ // Fallback: if jobDir exists (e.g. created by EventStore), synthesize metadata
114
+ if (fs.existsSync(jobDir)) {
115
+ const stat = fs.statSync(jobDir);
116
+ return {
117
+ id: jobId,
118
+ name: normalizeJobSlug(jobId),
119
+ skillId,
120
+ status: 'active',
121
+ currentState: 'INIT',
122
+ createdAt: stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime.toISOString() : new Date().toISOString(),
123
+ updatedAt: stat.mtime && !isNaN(stat.mtime.getTime()) ? stat.mtime.toISOString() : new Date().toISOString(),
124
+ };
125
+ }
126
+ // Check if it's the active job pointer
127
+ if (jobId === this.getActiveJobId(skillId)) {
128
+ const pointerFile = this.getActivePointerPath(skillId);
129
+ if (fs.existsSync(pointerFile)) {
130
+ const stat = fs.statSync(pointerFile);
131
+ return {
132
+ id: jobId,
133
+ name: normalizeJobSlug(jobId),
134
+ skillId,
135
+ status: 'active',
136
+ currentState: 'INIT',
137
+ createdAt: stat.birthtime && !isNaN(stat.birthtime.getTime()) ? stat.birthtime.toISOString() : new Date().toISOString(),
138
+ updatedAt: stat.mtime && !isNaN(stat.mtime.getTime()) ? stat.mtime.toISOString() : new Date().toISOString(),
139
+ };
140
+ }
141
+ }
142
+ // Legacy fallback: if requesting 'default' and legacy events.jsonl exists at skill root
143
+ if (jobId === DEFAULT_JOB_ID) {
144
+ const legacyEventStore = path.join(this.getSkillDir(skillId), 'events.jsonl');
145
+ if (fs.existsSync(legacyEventStore)) {
146
+ return {
147
+ id: DEFAULT_JOB_ID,
148
+ name: DEFAULT_JOB_ID,
149
+ skillId,
150
+ status: 'active',
151
+ currentState: 'ACTIVE',
152
+ createdAt: new Date().toISOString(),
153
+ updatedAt: new Date().toISOString(),
154
+ };
155
+ }
156
+ }
157
+ return null;
158
+ }
159
+ /**
160
+ * Updates a job's metadata (e.g. status, currentState, completedAt).
161
+ */
162
+ updateJob(skillId, jobId, updates) {
163
+ const existing = this.getJob(skillId, jobId) || this.createJob(skillId, { id: jobId, name: jobId });
164
+ const updated = {
165
+ ...existing,
166
+ ...updates,
167
+ updatedAt: new Date().toISOString(),
168
+ };
169
+ const validated = JobMetadataSchema.parse(updated);
170
+ const jobDir = this.getJobDir(skillId, jobId);
171
+ if (!fs.existsSync(jobDir)) {
172
+ fs.mkdirSync(jobDir, { recursive: true });
173
+ }
174
+ const metadataPath = path.join(jobDir, 'job.json');
175
+ fs.writeFileSync(metadataPath, JSON.stringify(validated, null, 2), 'utf8');
176
+ return validated;
177
+ }
178
+ /**
179
+ * Lists all jobs for a skill.
180
+ */
181
+ listJobs(skillId) {
182
+ const jobs = [];
183
+ const jobsDir = this.getJobsDir(skillId);
184
+ if (fs.existsSync(jobsDir)) {
185
+ const entries = fs.readdirSync(jobsDir, { withFileTypes: true });
186
+ for (const entry of entries) {
187
+ if (entry.isDirectory()) {
188
+ const job = this.getJob(skillId, entry.name);
189
+ if (job) {
190
+ jobs.push(job);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ const activeJobId = this.getActiveJobId(skillId);
196
+ if (!jobs.some(j => j.id === activeJobId)) {
197
+ const activeJob = this.getJob(skillId, activeJobId);
198
+ if (activeJob) {
199
+ jobs.push(activeJob);
200
+ }
201
+ }
202
+ // Check for legacy root event store if no jobs found
203
+ if (jobs.length === 0) {
204
+ const legacyEventStore = path.join(this.getSkillDir(skillId), 'events.jsonl');
205
+ if (fs.existsSync(legacyEventStore)) {
206
+ const defaultJob = this.getJob(skillId, DEFAULT_JOB_ID);
207
+ if (defaultJob)
208
+ jobs.push(defaultJob);
209
+ }
210
+ }
211
+ return jobs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
212
+ }
213
+ }
@@ -30,7 +30,10 @@ export declare class ProjectionEngine {
30
30
  private projections;
31
31
  private compiledTemplates;
32
32
  private eventCaches;
33
- constructor(skillDir: string, projections?: DeliverableProjection[], workspaceDir?: string);
33
+ private jobId?;
34
+ private isActiveJob;
35
+ constructor(skillDir: string, projections?: DeliverableProjection[], workspaceDir?: string, jobId?: string, isActiveJob?: boolean);
36
+ setJob(jobId?: string, isActiveJob?: boolean): void;
34
37
  private resolveOutputPath;
35
38
  private getProjectionEvents;
36
39
  private matchesTrigger;
@@ -11,13 +11,21 @@ export class ProjectionEngine {
11
11
  projections;
12
12
  compiledTemplates = new Map();
13
13
  eventCaches = new WeakMap();
14
- constructor(skillDir, projections = [], workspaceDir = process.cwd()) {
14
+ jobId;
15
+ isActiveJob;
16
+ constructor(skillDir, projections = [], workspaceDir = process.cwd(), jobId, isActiveJob = true) {
15
17
  this.skillDir = skillDir;
16
18
  this.workspaceDir = path.resolve(workspaceDir);
17
19
  this.projections = projections;
20
+ this.jobId = jobId;
21
+ this.isActiveJob = isActiveJob;
18
22
  this.registerHelpers();
19
23
  this.compileTemplates();
20
24
  }
25
+ setJob(jobId, isActiveJob = true) {
26
+ this.jobId = jobId;
27
+ this.isActiveJob = isActiveJob;
28
+ }
21
29
  resolveOutputPath(output) {
22
30
  if (path.isAbsolute(output)) {
23
31
  throw new Error('Projection output must be relative to the workspace.');
@@ -138,13 +146,27 @@ export class ProjectionEngine {
138
146
  }
139
147
  if (templateFn) {
140
148
  const outputContent = templateFn(projContext);
141
- const outputPath = this.resolveOutputPath(proj.output);
142
- const outputDir = path.dirname(outputPath);
143
- if (!fs.existsSync(outputDir)) {
144
- fs.mkdirSync(outputDir, { recursive: true });
149
+ const canonicalOutputPath = this.resolveOutputPath(proj.output);
150
+ const canonicalDir = path.dirname(canonicalOutputPath);
151
+ const fileName = path.basename(canonicalOutputPath);
152
+ // 1. If jobId is present, write to historical job archive:
153
+ if (this.jobId) {
154
+ const archiveDir = path.join(canonicalDir, 'jobs', this.jobId);
155
+ if (!fs.existsSync(archiveDir)) {
156
+ fs.mkdirSync(archiveDir, { recursive: true });
157
+ }
158
+ const archivePath = path.join(archiveDir, fileName);
159
+ fs.writeFileSync(archivePath, outputContent, 'utf8');
160
+ writtenFiles.push(archivePath);
161
+ }
162
+ // 2. If active job or no jobId (legacy mode), mirror to canonical root:
163
+ if (this.isActiveJob || !this.jobId) {
164
+ if (!fs.existsSync(canonicalDir)) {
165
+ fs.mkdirSync(canonicalDir, { recursive: true });
166
+ }
167
+ fs.writeFileSync(canonicalOutputPath, outputContent, 'utf8');
168
+ writtenFiles.push(canonicalOutputPath);
145
169
  }
146
- fs.writeFileSync(outputPath, outputContent, 'utf8');
147
- writtenFiles.push(outputPath);
148
170
  eventStore.saveProjectionWatermark(proj.template, eventStore.getLatestSequence(), `${this.skillDir}:${proj.template}`);
149
171
  }
150
172
  }
@@ -236,3 +236,37 @@ export declare const SkillManifestSchema: z.ZodObject<{
236
236
  trigger_on?: string[] | undefined;
237
237
  }[] | undefined;
238
238
  }>;
239
+ export declare const JobStatusSchema: z.ZodEnum<["active", "completed", "failed", "archived"]>;
240
+ export type JobStatus = z.infer<typeof JobStatusSchema>;
241
+ export declare const JobMetadataSchema: z.ZodObject<{
242
+ id: z.ZodString;
243
+ name: z.ZodString;
244
+ skillId: z.ZodString;
245
+ status: z.ZodEnum<["active", "completed", "failed", "archived"]>;
246
+ currentState: z.ZodString;
247
+ parentRunId: z.ZodOptional<z.ZodString>;
248
+ createdAt: z.ZodString;
249
+ updatedAt: z.ZodString;
250
+ completedAt: z.ZodOptional<z.ZodString>;
251
+ }, "strip", z.ZodTypeAny, {
252
+ status: "completed" | "failed" | "active" | "archived";
253
+ name: string;
254
+ id: string;
255
+ skillId: string;
256
+ currentState: string;
257
+ createdAt: string;
258
+ updatedAt: string;
259
+ parentRunId?: string | undefined;
260
+ completedAt?: string | undefined;
261
+ }, {
262
+ status: "completed" | "failed" | "active" | "archived";
263
+ name: string;
264
+ id: string;
265
+ skillId: string;
266
+ currentState: string;
267
+ createdAt: string;
268
+ updatedAt: string;
269
+ parentRunId?: string | undefined;
270
+ completedAt?: string | undefined;
271
+ }>;
272
+ export type JobMetadata = z.infer<typeof JobMetadataSchema>;
@@ -53,3 +53,15 @@ export const SkillManifestSchema = z.object({
53
53
  trigger_on: z.array(z.string()).optional(),
54
54
  })).optional(),
55
55
  });
56
+ export const JobStatusSchema = z.enum(['active', 'completed', 'failed', 'archived']);
57
+ export const JobMetadataSchema = z.object({
58
+ id: z.string(),
59
+ name: z.string(),
60
+ skillId: z.string(),
61
+ status: JobStatusSchema,
62
+ currentState: z.string(),
63
+ parentRunId: z.string().optional(),
64
+ createdAt: z.string(),
65
+ updatedAt: z.string(),
66
+ completedAt: z.string().optional(),
67
+ });
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export * from './core/fsm-engine.js';
6
6
  export * from './core/runtime-hooks.js';
7
7
  export * from './core/legacy-adapter.js';
8
8
  export * from './core/migration.js';
9
+ export * from './core/job-manager.js';
9
10
  export * from './mcp/server.js';
10
11
  export * from './sync/types.js';
11
12
  export { runSync } from './sync/engine.js';
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ export * from './core/fsm-engine.js';
6
6
  export * from './core/runtime-hooks.js';
7
7
  export * from './core/legacy-adapter.js';
8
8
  export * from './core/migration.js';
9
+ export * from './core/job-manager.js';
9
10
  export * from './mcp/server.js';
10
11
  export * from './sync/types.js';
11
12
  export { runSync } from './sync/engine.js';
@@ -7,6 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { z } from 'zod';
8
8
  import { FSMEngine } from '../core/fsm-engine.js';
9
9
  import { EventStore } from '../core/event-store.js';
10
+ import { JobManager } from '../core/job-manager.js';
10
11
  import { SkillManifestSchema } from '../core/types.js';
11
12
  export function createReactiveMcpServer(options = {}) {
12
13
  const workspaceDir = options.workspaceDir || process.cwd();
@@ -15,7 +16,7 @@ export function createReactiveMcpServer(options = {}) {
15
16
  name: 'reactive-skills-server',
16
17
  version: '1.0.0',
17
18
  });
18
- // Cached active engine instance per skill
19
+ // Cached active engine instance per skill and job
19
20
  const engines = new Map();
20
21
  function normalizeDeliverableName(name) {
21
22
  const trimmed = String(name || '').trim();
@@ -27,13 +28,16 @@ export function createReactiveMcpServer(options = {}) {
27
28
  }
28
29
  return trimmed;
29
30
  }
30
- function getEngine(skillName = defaultSkill) {
31
- if (engines.has(skillName)) {
32
- const cached = engines.get(skillName);
31
+ function getEngine(skillName = defaultSkill, jobId) {
32
+ const jobManager = new JobManager(workspaceDir);
33
+ const resolvedJobId = jobId || jobManager.getActiveJobId(skillName);
34
+ const cacheKey = `${skillName}::${resolvedJobId}`;
35
+ if (engines.has(cacheKey)) {
36
+ const cached = engines.get(cacheKey);
33
37
  if (fs.existsSync(cached.getSkillDir())) {
34
38
  return cached;
35
39
  }
36
- engines.delete(skillName);
40
+ engines.delete(cacheKey);
37
41
  }
38
42
  const candidatePaths = [
39
43
  path.resolve(workspaceDir, 'skills', skillName),
@@ -74,18 +78,26 @@ export function createReactiveMcpServer(options = {}) {
74
78
  const eventStore = new EventStore({
75
79
  workspaceDir,
76
80
  skillId: skillName,
81
+ jobId: resolvedJobId,
82
+ runId: resolvedJobId,
77
83
  enableSqlite: true,
78
84
  });
79
- const engine = new FSMEngine({ skillDir, workspaceDir, eventStore });
80
- engines.set(skillName, engine);
85
+ const engine = new FSMEngine({
86
+ skillDir,
87
+ workspaceDir,
88
+ eventStore,
89
+ jobId: resolvedJobId,
90
+ });
91
+ engines.set(cacheKey, engine);
81
92
  return engine;
82
93
  }
83
94
  // 1. TOOL: reactive_state
84
95
  server.tool('reactive_state', 'Get current state, prompt slice, and allowed tools for the active reactive skill', {
85
96
  skill: z.string().optional().describe('Skill name (defaults to active skill)'),
86
- }, async ({ skill }) => {
97
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
98
+ }, async ({ skill, job_id }) => {
87
99
  try {
88
- const engine = getEngine(skill || defaultSkill);
100
+ const engine = getEngine(skill || defaultSkill, job_id);
89
101
  if (engine.isBypassDetected()) {
90
102
  return {
91
103
  content: [
@@ -114,6 +126,7 @@ export function createReactiveMcpServer(options = {}) {
114
126
  type: 'text',
115
127
  text: JSON.stringify({
116
128
  skill: engine.getManifest().name,
129
+ job_id: engine.getJobId(),
117
130
  activeState,
118
131
  isWaitingForHuman: isWaiting,
119
132
  allowedTools: slice.allowedTools,
@@ -145,15 +158,18 @@ export function createReactiveMcpServer(options = {}) {
145
158
  signal: z.string().describe('Signal name (e.g. CHECK_PASSED, CHARTER_DRAFTED)'),
146
159
  payload: z.record(z.any()).optional().describe('Signal payload data (e.g. exit_code, file_path)'),
147
160
  skill: z.string().optional().describe('Target skill name'),
148
- }, async ({ signal, payload = {}, skill }) => {
161
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
162
+ }, async ({ signal, payload = {}, skill, job_id }) => {
149
163
  try {
150
- const engine = getEngine(skill || defaultSkill);
164
+ const engine = getEngine(skill || defaultSkill, job_id);
151
165
  const result = await engine.handleSignal(signal, payload);
152
166
  return {
153
167
  content: [
154
168
  {
155
169
  type: 'text',
156
170
  text: JSON.stringify({
171
+ skill: engine.getManifest().name,
172
+ job_id: engine.getJobId(),
157
173
  transitioned: result.transitioned,
158
174
  previousState: result.previousState,
159
175
  newState: result.newState,
@@ -175,9 +191,10 @@ export function createReactiveMcpServer(options = {}) {
175
191
  server.tool('reactive_query', 'Execute a read-only SQL query against the SQLite event store (.reactive/events.db)', {
176
192
  sql: z.string().describe('SQL query string (e.g. SELECT * FROM events ORDER BY seq DESC LIMIT 10)'),
177
193
  skill: z.string().optional().describe('Skill context for event store'),
178
- }, async ({ sql, skill }) => {
194
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
195
+ }, async ({ sql, skill, job_id }) => {
179
196
  try {
180
- const engine = getEngine(skill || defaultSkill);
197
+ const engine = getEngine(skill || defaultSkill, job_id);
181
198
  const driver = engine.getEventStore().getSqliteDriver();
182
199
  if (!driver) {
183
200
  throw new Error('SQLite storage driver is not active.');
@@ -206,9 +223,10 @@ export function createReactiveMcpServer(options = {}) {
206
223
  sinceSeq: z.number().int().nonnegative().optional().describe('Return events after this sequence'),
207
224
  limit: z.number().int().positive().max(1000).optional().describe('Maximum number of events'),
208
225
  skill: z.string().optional().describe('Skill context for event store'),
209
- }, async ({ type, state, sinceSeq, limit, skill }) => {
226
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
227
+ }, async ({ type, state, sinceSeq, limit, skill, job_id }) => {
210
228
  try {
211
- const engine = getEngine(skill || defaultSkill);
229
+ const engine = getEngine(skill || defaultSkill, job_id);
212
230
  const rows = engine.getEventStore().query({ type, state, sinceSeq, limit });
213
231
  return {
214
232
  content: [{ type: 'text', text: JSON.stringify(rows, null, 2) }],
@@ -268,9 +286,10 @@ export function createReactiveMcpServer(options = {}) {
268
286
  // 5. TOOL: reactive_inspect
269
287
  server.tool('reactive_inspect', 'Inspect the full statechart, transitions, and guard criteria of a reactive skill', {
270
288
  skill: z.string().optional().describe('Skill name to inspect'),
271
- }, async ({ skill }) => {
289
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
290
+ }, async ({ skill, job_id }) => {
272
291
  try {
273
- const engine = getEngine(skill || defaultSkill);
292
+ const engine = getEngine(skill || defaultSkill, job_id);
274
293
  const manifest = engine.getManifest();
275
294
  return {
276
295
  content: [
@@ -319,15 +338,16 @@ export function createReactiveMcpServer(options = {}) {
319
338
  };
320
339
  }
321
340
  });
322
- // 6. TOOL: reactive_respond_human
341
+ // 7. TOOL: reactive_respond_human
323
342
  server.tool('reactive_respond_human', 'Submit user approval or feedback to unpause a Human-in-the-Loop (HITL) gate', {
324
343
  choice: z.string().describe('User selected choice (e.g. Approve Plan)'),
325
344
  approved: z.boolean().optional().describe('Explicit approval boolean flag'),
326
345
  feedback: z.string().optional().describe('Optional feedback text'),
327
346
  skill: z.string().optional().describe('Target skill name'),
328
- }, async ({ choice, approved = true, feedback, skill }) => {
347
+ job_id: z.string().optional().describe('Optional job/run ID (defaults to active job)'),
348
+ }, async ({ choice, approved = true, feedback, skill, job_id }) => {
329
349
  try {
330
- const engine = getEngine(skill || defaultSkill);
350
+ const engine = getEngine(skill || defaultSkill, job_id);
331
351
  const signalsEmitted = [];
332
352
  let transitioned = false;
333
353
  let deliverablesWritten = [];
@@ -427,6 +447,35 @@ export function createReactiveMcpServer(options = {}) {
427
447
  };
428
448
  }
429
449
  });
450
+ // 9. TOOL: reactive_list_jobs
451
+ server.tool('reactive_list_jobs', 'List all historical and active execution jobs for a skill with status metadata', {
452
+ skill: z.string().optional().describe('Skill name (defaults to active skill)'),
453
+ }, async ({ skill }) => {
454
+ try {
455
+ const targetSkill = skill || defaultSkill;
456
+ const jobManager = new JobManager(workspaceDir);
457
+ const activeJobId = jobManager.getActiveJobId(targetSkill);
458
+ const jobs = jobManager.listJobs(targetSkill);
459
+ return {
460
+ content: [
461
+ {
462
+ type: 'text',
463
+ text: JSON.stringify({
464
+ skill: targetSkill,
465
+ activeJobId,
466
+ jobs,
467
+ }, null, 2),
468
+ },
469
+ ],
470
+ };
471
+ }
472
+ catch (err) {
473
+ return {
474
+ content: [{ type: 'text', text: JSON.stringify({ error: err.message }) }],
475
+ isError: true,
476
+ };
477
+ }
478
+ });
430
479
  // RESOURCE 1: reactive://events
431
480
  server.resource('reactive-events', 'reactive://events', async (uri) => {
432
481
  const store = new EventStore({ enableSqlite: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reactive-skills/runtime",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Reactive Skills Architecture (RSA) core runtime โ€” FSM engine, event store, guard evaluator, projection engine, MCP server",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,7 +14,8 @@
14
14
  "reactive-skills-mcp": "./dist/mcp/server.js"
15
15
  },
16
16
  "files": [
17
- "dist"
17
+ "dist",
18
+ "CHANGELOG.md"
18
19
  ],
19
20
  "keywords": [
20
21
  "ai",