@nt-ai-lab/deterministic-agent-workflow-engine 0.2.1 → 0.3.1

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,72 @@
1
+ import { z } from 'zod';
2
+ export const reflectionCategorySchema = z.enum([
3
+ 'state-efficiency',
4
+ 'review-rework',
5
+ 'quality-gates',
6
+ 'tooling',
7
+ 'workflow-design',
8
+ ]);
9
+ export const reflectionConfidenceSchema = z.enum(['low', 'medium', 'high']);
10
+ const evidenceBaseSchema = z.object({ label: z.string().min(1).optional(), });
11
+ export const reflectionEvidenceSchema = z.discriminatedUnion('kind', [
12
+ evidenceBaseSchema.extend({
13
+ kind: z.literal('state-period'),
14
+ state: z.string().min(1),
15
+ startedAt: z.string().min(1).optional(),
16
+ endedAt: z.string().min(1).optional(),
17
+ }),
18
+ evidenceBaseSchema.extend({
19
+ kind: z.literal('event'),
20
+ seq: z.number().int().positive(),
21
+ }),
22
+ evidenceBaseSchema.extend({
23
+ kind: z.literal('event-range'),
24
+ startSeq: z.number().int().positive(),
25
+ endSeq: z.number().int().positive(),
26
+ }),
27
+ evidenceBaseSchema.extend({
28
+ kind: z.literal('journal-entry'),
29
+ at: z.string().min(1),
30
+ agentName: z.string().min(1).optional(),
31
+ }),
32
+ evidenceBaseSchema.extend({
33
+ kind: z.literal('transcript-range'),
34
+ startIndex: z.number().int().nonnegative(),
35
+ endIndex: z.number().int().nonnegative(),
36
+ }),
37
+ evidenceBaseSchema.extend({
38
+ kind: z.literal('tool-activity'),
39
+ state: z.string().min(1).optional(),
40
+ toolName: z.string().min(1).optional(),
41
+ metric: z.string().min(1).optional(),
42
+ }),
43
+ ]);
44
+ export const reflectionFindingSchema = z.object({
45
+ title: z.string().min(1),
46
+ category: reflectionCategorySchema,
47
+ opportunity: z.string().min(1),
48
+ likelyCause: z.string().min(1),
49
+ suggestedChange: z.string().min(1),
50
+ expectedImpact: z.string().min(1),
51
+ confidence: reflectionConfidenceSchema.optional(),
52
+ evidence: z.array(reflectionEvidenceSchema).min(1),
53
+ });
54
+ export const reflectionPayloadSchema = z.object({
55
+ summary: z.string().min(1).optional(),
56
+ findings: z.array(reflectionFindingSchema).max(10),
57
+ }).strict();
58
+ export const recordReflectionInputSchema = z.object({
59
+ label: z.string().min(1).optional(),
60
+ agentName: z.string().min(1).optional(),
61
+ sourceState: z.string().min(1).optional(),
62
+ reflection: reflectionPayloadSchema,
63
+ }).strict();
64
+ export const storedReflectionSchema = z.object({
65
+ id: z.number().int().positive(),
66
+ sessionId: z.string().min(1),
67
+ createdAt: z.string().min(1),
68
+ label: z.string().min(1).optional(),
69
+ agentName: z.string().min(1).optional(),
70
+ sourceState: z.string().min(1).optional(),
71
+ reflection: reflectionPayloadSchema,
72
+ }).strict();
@@ -1,5 +1,6 @@
1
1
  import type { ZodType } from 'zod';
2
2
  import type { BaseEvent } from './base-event';
3
+ import type { RecordReflectionInput, StoredReflection } from './reflection-types';
3
4
  import type { StoredEvent } from './stored-event';
4
5
  import type { PreconditionResult } from './precondition-result';
5
6
  import type { TranscriptReader } from '../infra/external-clients/transcript/transcript-reader';
@@ -44,6 +45,8 @@ export interface WorkflowEventStore {
44
45
  appendEvents(sessionId: string, events: readonly StoredEvent[]): void;
45
46
  sessionExists(sessionId: string): boolean;
46
47
  hasSessionStarted(sessionId: string): boolean;
48
+ recordReflection(sessionId: string, createdAt: string, input: RecordReflectionInput): StoredReflection;
49
+ listReflections(sessionId: string): readonly StoredReflection[];
47
50
  }
48
51
  /** @riviere-role value-object */
49
52
  export type WorkflowEngineDeps = {
@@ -1,7 +1,7 @@
1
1
  import type { PreconditionResult } from './precondition-result';
2
2
  import type { BashForbiddenConfig } from './workflow-registry';
3
3
  import type { EngineResult, RehydratableWorkflow, WorkflowDefinition, WorkflowEngineDeps } from './workflow-engine-types';
4
- import type { BaseWorkflowState } from './workflow-state';
4
+ import { type BaseWorkflowState } from './workflow-state';
5
5
  /** @riviere-role domain-service */
6
6
  export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> {
7
7
  private readonly factory;
@@ -13,6 +13,7 @@ export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TStat
13
13
  transition(sessionId: string, target: TStateName): EngineResult;
14
14
  checkBash(sessionId: string, toolName: string, command: string, bashForbidden: BashForbiddenConfig): EngineResult;
15
15
  checkWrite(sessionId: string, toolName: string, filePath: string, isWriteAllowed: (filePath: string, state: TState) => boolean): EngineResult;
16
+ getState(sessionId: string): EngineResult;
16
17
  persistSessionId(sessionId: string): void;
17
18
  hasSession(sessionId: string): boolean;
18
19
  hasSessionStarted(sessionId: string): boolean;
@@ -20,5 +21,6 @@ export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TStat
20
21
  private rehydrateFromEvents;
21
22
  private persistEvents;
22
23
  private wrapEvents;
24
+ private applyIdentityGate;
23
25
  private verifyIdentity;
24
26
  }
@@ -2,8 +2,9 @@ import { checkBashCommand } from './bash-enforcement.js';
2
2
  import { checkIdentity } from './identity-verification.js';
3
3
  import { buildPrefixPattern, buildProcedurePath, enrichSessionStartedEvents, getExpectedPrefix, readProcedure, } from './workflow-engine-support.js';
4
4
  import { formatIllegalTransitionError, formatInitSuccess, formatOperationGateError, formatOperationSuccess, formatTransitionError, formatTransitionSuccess, } from '../infra/cli/presentation/output-guidance.js';
5
- import { flattenStoredEvent, toPayload } from './stored-event.js';
6
- import { WorkflowStateError } from './workflow-state.js';
5
+ import { flattenStoredEvent, toPayload, } from './stored-event.js';
6
+ import { WorkflowStateError, } from './workflow-state.js';
7
+ import { serializeWorkflowState } from './workflow-state-serialization.js';
7
8
  /** @riviere-role domain-service */
8
9
  export class WorkflowEngine {
9
10
  factory;
@@ -40,15 +41,9 @@ export class WorkflowEngine {
40
41
  this.requireSession(sessionId);
41
42
  const workflow = this.rehydrateFromEvents(sessionId);
42
43
  const registry = this.factory.getRegistry();
43
- const identityResult = this.verifyIdentity(sessionId, workflow);
44
- if (identityResult !== undefined) {
45
- this.persistEvents(sessionId, workflow);
46
- const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
47
- return {
48
- type: 'blocked',
49
- output: formatOperationGateError(op, identityResult, currentPrefix)
50
- };
51
- }
44
+ const gate = this.applyIdentityGate(sessionId, workflow, op);
45
+ if (gate !== undefined)
46
+ return gate;
52
47
  const result = fn(workflow);
53
48
  this.persistEvents(sessionId, workflow);
54
49
  const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
@@ -70,15 +65,9 @@ export class WorkflowEngine {
70
65
  const state = workflow.getState();
71
66
  const currentStateName = state.currentStateMachineState;
72
67
  const registry = this.factory.getRegistry();
73
- const identityResult = this.verifyIdentity(sessionId, workflow);
74
- if (identityResult !== undefined) {
75
- this.persistEvents(sessionId, workflow);
76
- const currentPrefix = getExpectedPrefix(currentStateName, registry);
77
- return {
78
- type: 'blocked',
79
- output: formatOperationGateError('transition', identityResult, currentPrefix)
80
- };
81
- }
68
+ const gate = this.applyIdentityGate(sessionId, workflow, 'transition');
69
+ if (gate !== undefined)
70
+ return gate;
82
71
  const currentDef = registry[currentStateName];
83
72
  if (!currentDef.canTransitionTo.includes(target)) {
84
73
  const legalTargets = currentDef.canTransitionTo;
@@ -133,14 +122,9 @@ export class WorkflowEngine {
133
122
  const registry = this.factory.getRegistry();
134
123
  const currentStateName = workflow.getState().currentStateMachineState;
135
124
  const currentPrefix = getExpectedPrefix(currentStateName, registry);
136
- const identityResult = this.verifyIdentity(sessionId, workflow);
137
- if (identityResult !== undefined) {
138
- this.persistEvents(sessionId, workflow);
139
- return {
140
- type: 'blocked',
141
- output: formatOperationGateError('bash-check', identityResult, currentPrefix)
142
- };
143
- }
125
+ const gate = this.applyIdentityGate(sessionId, workflow, 'bash-check');
126
+ if (gate !== undefined)
127
+ return gate;
144
128
  if (toolName !== 'Bash') {
145
129
  workflow.appendEvent({
146
130
  type: 'bash-checked',
@@ -192,14 +176,9 @@ export class WorkflowEngine {
192
176
  const registry = this.factory.getRegistry();
193
177
  const currentStateName = workflow.getState().currentStateMachineState;
194
178
  const currentPrefix = getExpectedPrefix(currentStateName, registry);
195
- const identityResult = this.verifyIdentity(sessionId, workflow);
196
- if (identityResult !== undefined) {
197
- this.persistEvents(sessionId, workflow);
198
- return {
199
- type: 'blocked',
200
- output: formatOperationGateError('write-check', identityResult, currentPrefix)
201
- };
202
- }
179
+ const gate = this.applyIdentityGate(sessionId, workflow, 'write-check');
180
+ if (gate !== undefined)
181
+ return gate;
203
182
  const writeTools = new Set(['Write', 'Edit', 'NotebookEdit']);
204
183
  if (!writeTools.has(toolName)) {
205
184
  workflow.appendEvent({
@@ -273,6 +252,10 @@ export class WorkflowEngine {
273
252
  output: ''
274
253
  };
275
254
  }
255
+ getState(sessionId) {
256
+ this.requireSession(sessionId);
257
+ return serializeWorkflowState(this.rehydrateFromEvents(sessionId).getState());
258
+ }
276
259
  persistSessionId(sessionId) {
277
260
  this.engineDeps.appendToFile(this.engineDeps.getEnvFilePath(), `export CLAUDE_SESSION_ID='${sessionId}'\n`);
278
261
  }
@@ -317,11 +300,21 @@ export class WorkflowEngine {
317
300
  });
318
301
  return stored;
319
302
  }
303
+ applyIdentityGate(sessionId, workflow, op) {
304
+ const identityResult = this.verifyIdentity(sessionId, workflow);
305
+ if (identityResult === undefined)
306
+ return undefined;
307
+ this.persistEvents(sessionId, workflow);
308
+ const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, this.factory.getRegistry());
309
+ return {
310
+ type: 'blocked',
311
+ output: formatOperationGateError(op, identityResult, currentPrefix),
312
+ };
313
+ }
320
314
  verifyIdentity(sessionId, workflow) {
321
315
  const transcriptPath = workflow.getTranscriptPath();
322
316
  const state = workflow.getState().currentStateMachineState;
323
317
  const registry = this.factory.getRegistry();
324
- const expectedPrefix = getExpectedPrefix(state, registry);
325
318
  const pattern = buildPrefixPattern(registry);
326
319
  const messages = this.engineDeps.transcriptReader.readMessages(transcriptPath);
327
320
  const identityCheckResult = checkIdentity(messages, pattern);
@@ -332,7 +325,17 @@ export class WorkflowEngine {
332
325
  transcriptPath,
333
326
  }], workflow.getState()));
334
327
  if (identityCheckResult.status === 'lost') {
335
- return `You forgot. Next message MUST begin with: ${expectedPrefix}`;
328
+ const currentProcedure = readProcedure(this.engineDeps, state);
329
+ return [
330
+ 'Your last message is missing the required state prefix.',
331
+ '',
332
+ `- send a new message starting with: ${getExpectedPrefix(state, registry)}`,
333
+ '- then continue with the current procedure',
334
+ '',
335
+ 'Current procedure:',
336
+ '',
337
+ currentProcedure,
338
+ ].join('\n');
336
339
  }
337
340
  return undefined;
338
341
  }
@@ -0,0 +1,3 @@
1
+ import type { EngineResult } from './workflow-engine-types';
2
+ /** @riviere-role domain-service */
3
+ export declare function serializeWorkflowState(state: unknown): EngineResult;
@@ -0,0 +1,16 @@
1
+ /** @riviere-role domain-service */
2
+ export function serializeWorkflowState(state) {
3
+ try {
4
+ return {
5
+ type: 'success',
6
+ output: JSON.stringify(state, null, 2),
7
+ };
8
+ }
9
+ catch (error) {
10
+ const message = error instanceof Error ? error.message : String(error);
11
+ return {
12
+ type: 'error',
13
+ output: `Failed to serialize workflow state: ${message}`,
14
+ };
15
+ }
16
+ }
@@ -0,0 +1,184 @@
1
+ import { z } from 'zod';
2
+ import { WorkflowEngine, pass, } from '../../../../index.js';
3
+ import { SEPARATOR } from './output-guidance.js';
4
+ function isSessionStartedEvent(event) {
5
+ return event.type === 'session-started';
6
+ }
7
+ class PlanningWorkflow {
8
+ state;
9
+ pendingEvents;
10
+ constructor(state, pendingEvents = []) {
11
+ this.state = state;
12
+ this.pendingEvents = pendingEvents;
13
+ }
14
+ getState() {
15
+ return this.state;
16
+ }
17
+ appendEvent(event) {
18
+ this.pendingEvents = [...this.pendingEvents, event];
19
+ if (isSessionStartedEvent(event)) {
20
+ this.state = {
21
+ ...this.state,
22
+ transcriptPath: event.transcriptPath,
23
+ };
24
+ }
25
+ }
26
+ getPendingEvents() {
27
+ return this.pendingEvents;
28
+ }
29
+ startSession(transcriptPath, _repository) {
30
+ void _repository;
31
+ this.state = {
32
+ ...this.state,
33
+ transcriptPath,
34
+ };
35
+ this.pendingEvents = [...this.pendingEvents, {
36
+ type: 'session-started',
37
+ at: '2026-01-01T00:00:00Z',
38
+ transcriptPath,
39
+ currentState: this.state.currentStateMachineState,
40
+ states: ['PLANNING'],
41
+ }];
42
+ }
43
+ getTranscriptPath() {
44
+ return this.state.transcriptPath;
45
+ }
46
+ registerAgent(_agentType, _agentId) {
47
+ void _agentType;
48
+ void _agentId;
49
+ return pass();
50
+ }
51
+ handleTeammateIdle(_agentName) {
52
+ void _agentName;
53
+ return pass();
54
+ }
55
+ }
56
+ class ReflectionStoreNotConfiguredError extends Error {
57
+ constructor() {
58
+ super('Reflection storage is not configured for this test');
59
+ this.name = 'ReflectionStoreNotConfiguredError';
60
+ }
61
+ }
62
+ class MemoryStore {
63
+ eventsBySessionId = new Map();
64
+ readEvents(sessionId) {
65
+ return this.eventsBySessionId.get(sessionId) ?? [];
66
+ }
67
+ appendEvents(sessionId, events) {
68
+ const existingEvents = this.eventsBySessionId.get(sessionId) ?? [];
69
+ this.eventsBySessionId.set(sessionId, [...existingEvents, ...events]);
70
+ }
71
+ sessionExists(sessionId) {
72
+ return this.eventsBySessionId.has(sessionId);
73
+ }
74
+ hasSessionStarted(sessionId) {
75
+ return this.eventsBySessionId.get(sessionId)?.some((event) => {
76
+ return event.envelope.type === 'session-started';
77
+ }) ?? false;
78
+ }
79
+ recordReflection(_sessionId, _createdAt, _input) {
80
+ void _sessionId;
81
+ void _createdAt;
82
+ void _input;
83
+ throw new ReflectionStoreNotConfiguredError();
84
+ }
85
+ listReflections() {
86
+ return [];
87
+ }
88
+ }
89
+ const procedureContent = 'PLANNING instructions';
90
+ const workflowDefinition = {
91
+ fold(state, event) {
92
+ if (!isSessionStartedEvent(event)) {
93
+ return state;
94
+ }
95
+ return {
96
+ ...state,
97
+ transcriptPath: event.transcriptPath,
98
+ };
99
+ },
100
+ buildWorkflow(state, _deps) {
101
+ void _deps;
102
+ return new PlanningWorkflow(state);
103
+ },
104
+ stateSchema: z.literal('PLANNING'),
105
+ initialState() {
106
+ return {
107
+ currentStateMachineState: 'PLANNING',
108
+ transcriptPath: '',
109
+ };
110
+ },
111
+ getRegistry() {
112
+ return {
113
+ PLANNING: {
114
+ emoji: '🧭',
115
+ agentInstructions: procedureContent,
116
+ canTransitionTo: [],
117
+ allowedWorkflowOperations: ['write'],
118
+ forbidden: { write: true },
119
+ },
120
+ };
121
+ },
122
+ buildTransitionContext(state, from, to) {
123
+ return {
124
+ state,
125
+ from,
126
+ to,
127
+ gitInfo: {
128
+ currentBranch: 'main',
129
+ workingTreeClean: true,
130
+ headCommit: 'abc123',
131
+ changedFilesVsDefault: [],
132
+ hasCommitsVsDefault: false,
133
+ },
134
+ };
135
+ },
136
+ };
137
+ const transcriptReader = {
138
+ readMessages: () => [{
139
+ id: 'message-1',
140
+ textContent: 'plain text without the expected prefix',
141
+ }],
142
+ };
143
+ const engineDeps = {
144
+ store: new MemoryStore(),
145
+ getPluginRoot: () => '/plugin-root',
146
+ getEnvFilePath: () => '/plugin-root/.env',
147
+ readFile: (path) => {
148
+ if (path === '/plugin-root/states/planning.md') {
149
+ return procedureContent;
150
+ }
151
+ return '';
152
+ },
153
+ appendToFile: (filePath, content) => {
154
+ void filePath;
155
+ void content;
156
+ },
157
+ now: () => '2026-01-01T00:00:00Z',
158
+ transcriptReader,
159
+ };
160
+ it('reinserts the current procedure when identity verification fails', () => {
161
+ const emptyWorkflowDeps = {};
162
+ const engine = new WorkflowEngine(workflowDefinition, engineDeps, emptyWorkflowDeps);
163
+ engine.startSession('session-1', '/transcripts/session-1.jsonl');
164
+ const result = engine.checkWrite('session-1', 'Write', '/workspace/note.md', () => true);
165
+ const expectedOutput = [
166
+ 'Cannot write-check',
167
+ SEPARATOR,
168
+ 'Your last message is missing the required state prefix.',
169
+ '',
170
+ '- send a new message starting with: 🧭 PLANNING',
171
+ '- then continue with the current procedure',
172
+ '',
173
+ 'Current procedure:',
174
+ '',
175
+ procedureContent,
176
+ '',
177
+ 'Next message MUST begin with: 🧭 PLANNING',
178
+ ].join('\n');
179
+ const expectedResult = {
180
+ type: 'blocked',
181
+ output: expectedOutput,
182
+ };
183
+ expect(result).toEqual(expectedResult);
184
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nt-ai-lab/deterministic-agent-workflow-engine",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -13,6 +13,9 @@
13
13
  "dependencies": {
14
14
  "zod": "^3.25.76"
15
15
  },
16
+ "devDependencies": {
17
+ "vitest": "^2.1.9"
18
+ },
16
19
  "publishConfig": {
17
20
  "access": "public"
18
21
  }