@nt-ai-lab/deterministic-agent-workflow-engine 0.2.0 → 0.3.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,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();
@@ -0,0 +1,28 @@
1
+ import type { BaseEvent } from './base-event';
2
+ /** @riviere-role value-object */
3
+ export interface EventEnvelope {
4
+ readonly type: string;
5
+ readonly at: string;
6
+ readonly state: string | undefined;
7
+ }
8
+ /**
9
+ * Platform wire / storage shape for events.
10
+ *
11
+ * Envelope fields are stamped by the platform at persist time (see
12
+ * `WorkflowEngine.persistEvents`). Workflow authors never construct
13
+ * `StoredEvent` directly — they call `appendEvent(BaseEvent)` with flat
14
+ * domain events. Only the engine and direct event-store readers (e.g. the
15
+ * control-center UI) observe this shape.
16
+ *
17
+ * @riviere-role value-object
18
+ */
19
+ export interface StoredEvent {
20
+ readonly envelope: EventEnvelope;
21
+ readonly payload: Readonly<Record<string, unknown>>;
22
+ }
23
+ /** @riviere-role domain-service */
24
+ export declare function flattenStoredEvent(stored: StoredEvent): BaseEvent;
25
+ /** @riviere-role domain-service */
26
+ export declare function toPayload(event: BaseEvent): Record<string, unknown>;
27
+ /** @riviere-role domain-service */
28
+ export declare function stripEnvelopeKeys(record: Record<string, unknown>): Record<string, unknown>;
@@ -0,0 +1,22 @@
1
+ /** @riviere-role domain-service */
2
+ export function flattenStoredEvent(stored) {
3
+ return {
4
+ ...stripEnvelopeKeys(stored.payload),
5
+ type: stored.envelope.type,
6
+ at: stored.envelope.at,
7
+ };
8
+ }
9
+ /** @riviere-role domain-service */
10
+ export function toPayload(event) {
11
+ return stripEnvelopeKeys(event);
12
+ }
13
+ /** @riviere-role domain-service */
14
+ export function stripEnvelopeKeys(record) {
15
+ const result = {};
16
+ for (const [key, value] of Object.entries(record)) {
17
+ if (key === 'type' || key === 'at')
18
+ continue;
19
+ result[key] = value;
20
+ }
21
+ return result;
22
+ }
@@ -1,5 +1,7 @@
1
1
  import type { ZodType } from 'zod';
2
2
  import type { BaseEvent } from './base-event';
3
+ import type { RecordReflectionInput, StoredReflection } from './reflection-types';
4
+ import type { StoredEvent } from './stored-event';
3
5
  import type { PreconditionResult } from './precondition-result';
4
6
  import type { TranscriptReader } from '../infra/external-clients/transcript/transcript-reader';
5
7
  import type { BaseWorkflowState } from './workflow-state';
@@ -39,10 +41,12 @@ export interface WorkflowDefinition<TWorkflow extends RehydratableWorkflow<TStat
39
41
  }
40
42
  /** @riviere-role value-object */
41
43
  export interface WorkflowEventStore {
42
- readEvents(sessionId: string): readonly BaseEvent[];
43
- appendEvents(sessionId: string, events: readonly BaseEvent[]): void;
44
+ readEvents(sessionId: string): readonly StoredEvent[];
45
+ appendEvents(sessionId: string, events: readonly StoredEvent[]): void;
44
46
  sessionExists(sessionId: string): boolean;
45
47
  hasSessionStarted(sessionId: string): boolean;
48
+ recordReflection(sessionId: string, createdAt: string, input: RecordReflectionInput): StoredReflection;
49
+ listReflections(sessionId: string): readonly StoredReflection[];
46
50
  }
47
51
  /** @riviere-role value-object */
48
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,11 +13,14 @@ 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;
19
20
  private requireSession;
20
21
  private rehydrateFromEvents;
21
22
  private persistEvents;
23
+ private wrapEvents;
24
+ private applyIdentityGate;
22
25
  private verifyIdentity;
23
26
  }
@@ -2,7 +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 { 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';
6
8
  /** @riviere-role domain-service */
7
9
  export class WorkflowEngine {
8
10
  factory;
@@ -27,7 +29,7 @@ export class WorkflowEngine {
27
29
  const registry = this.factory.getRegistry();
28
30
  const stateNames = Object.keys(registry);
29
31
  const pendingEvents = enrichSessionStartedEvents(this.engineDeps, workflow.getPendingEvents(), transcriptPath, resolvedRepository, initialState.currentStateMachineState, stateNames);
30
- this.engineDeps.store.appendEvents(sessionId, pendingEvents);
32
+ this.engineDeps.store.appendEvents(sessionId, this.wrapEvents(pendingEvents, initialState));
31
33
  const procedureContent = this.engineDeps.readFile(buildProcedurePath(this.engineDeps, initialState.currentStateMachineState));
32
34
  const expectedPrefix = getExpectedPrefix(initialState.currentStateMachineState, registry);
33
35
  return {
@@ -39,15 +41,9 @@ export class WorkflowEngine {
39
41
  this.requireSession(sessionId);
40
42
  const workflow = this.rehydrateFromEvents(sessionId);
41
43
  const registry = this.factory.getRegistry();
42
- const identityResult = this.verifyIdentity(sessionId, workflow);
43
- if (identityResult !== undefined) {
44
- this.persistEvents(sessionId, workflow);
45
- const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
46
- return {
47
- type: 'blocked',
48
- output: formatOperationGateError(op, identityResult, currentPrefix)
49
- };
50
- }
44
+ const gate = this.applyIdentityGate(sessionId, workflow, op);
45
+ if (gate !== undefined)
46
+ return gate;
51
47
  const result = fn(workflow);
52
48
  this.persistEvents(sessionId, workflow);
53
49
  const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
@@ -69,15 +65,9 @@ export class WorkflowEngine {
69
65
  const state = workflow.getState();
70
66
  const currentStateName = state.currentStateMachineState;
71
67
  const registry = this.factory.getRegistry();
72
- const identityResult = this.verifyIdentity(sessionId, workflow);
73
- if (identityResult !== undefined) {
74
- this.persistEvents(sessionId, workflow);
75
- const currentPrefix = getExpectedPrefix(currentStateName, registry);
76
- return {
77
- type: 'blocked',
78
- output: formatOperationGateError('transition', identityResult, currentPrefix)
79
- };
80
- }
68
+ const gate = this.applyIdentityGate(sessionId, workflow, 'transition');
69
+ if (gate !== undefined)
70
+ return gate;
81
71
  const currentDef = registry[currentStateName];
82
72
  if (!currentDef.canTransitionTo.includes(target)) {
83
73
  const legalTargets = currentDef.canTransitionTo;
@@ -132,14 +122,9 @@ export class WorkflowEngine {
132
122
  const registry = this.factory.getRegistry();
133
123
  const currentStateName = workflow.getState().currentStateMachineState;
134
124
  const currentPrefix = getExpectedPrefix(currentStateName, registry);
135
- const identityResult = this.verifyIdentity(sessionId, workflow);
136
- if (identityResult !== undefined) {
137
- this.persistEvents(sessionId, workflow);
138
- return {
139
- type: 'blocked',
140
- output: formatOperationGateError('bash-check', identityResult, currentPrefix)
141
- };
142
- }
125
+ const gate = this.applyIdentityGate(sessionId, workflow, 'bash-check');
126
+ if (gate !== undefined)
127
+ return gate;
143
128
  if (toolName !== 'Bash') {
144
129
  workflow.appendEvent({
145
130
  type: 'bash-checked',
@@ -155,9 +140,9 @@ export class WorkflowEngine {
155
140
  };
156
141
  }
157
142
  const exemptions = registry[currentStateName].allowForbidden?.bash ?? [];
158
- const result = checkBashCommand(command, bashForbidden, exemptions);
159
- if (!result.pass) {
160
- const reason = `Bash command blocked in ${currentStateName}. ${result.reason}`;
143
+ const bashCheckResult = checkBashCommand(command, bashForbidden, exemptions);
144
+ if (!bashCheckResult.pass) {
145
+ const reason = `Bash command blocked in ${currentStateName}. ${bashCheckResult.reason}`;
161
146
  workflow.appendEvent({
162
147
  type: 'bash-checked',
163
148
  at: this.engineDeps.now(),
@@ -191,14 +176,9 @@ export class WorkflowEngine {
191
176
  const registry = this.factory.getRegistry();
192
177
  const currentStateName = workflow.getState().currentStateMachineState;
193
178
  const currentPrefix = getExpectedPrefix(currentStateName, registry);
194
- const identityResult = this.verifyIdentity(sessionId, workflow);
195
- if (identityResult !== undefined) {
196
- this.persistEvents(sessionId, workflow);
197
- return {
198
- type: 'blocked',
199
- output: formatOperationGateError('write-check', identityResult, currentPrefix)
200
- };
201
- }
179
+ const gate = this.applyIdentityGate(sessionId, workflow, 'write-check');
180
+ if (gate !== undefined)
181
+ return gate;
202
182
  const writeTools = new Set(['Write', 'Edit', 'NotebookEdit']);
203
183
  if (!writeTools.has(toolName)) {
204
184
  workflow.appendEvent({
@@ -272,6 +252,10 @@ export class WorkflowEngine {
272
252
  output: ''
273
253
  };
274
254
  }
255
+ getState(sessionId) {
256
+ this.requireSession(sessionId);
257
+ return serializeWorkflowState(this.rehydrateFromEvents(sessionId).getState());
258
+ }
275
259
  persistSessionId(sessionId) {
276
260
  this.engineDeps.appendToFile(this.engineDeps.getEnvFilePath(), `export CLAUDE_SESSION_ID='${sessionId}'\n`);
277
261
  }
@@ -287,15 +271,45 @@ export class WorkflowEngine {
287
271
  }
288
272
  }
289
273
  rehydrateFromEvents(sessionId) {
290
- const events = this.engineDeps.store.readEvents(sessionId);
274
+ const stored = this.engineDeps.store.readEvents(sessionId);
275
+ const events = stored.map(flattenStoredEvent);
291
276
  const state = events.reduce((accumulator, event) => this.factory.fold(accumulator, event), this.factory.initialState());
292
277
  return this.factory.buildWorkflow(state, this.workflowDeps);
293
278
  }
294
279
  persistEvents(sessionId, workflow) {
295
280
  const pending = workflow.getPendingEvents();
296
- if (pending.length > 0) {
297
- this.engineDeps.store.appendEvents(sessionId, pending);
298
- }
281
+ if (pending.length === 0)
282
+ return;
283
+ const preAppendState = this.rehydrateFromEvents(sessionId).getState();
284
+ this.engineDeps.store.appendEvents(sessionId, this.wrapEvents(pending, preAppendState));
285
+ }
286
+ wrapEvents(events, startState) {
287
+ const { stored } = events.reduce((accumulator, event) => ({
288
+ state: this.factory.fold(accumulator.state, event),
289
+ stored: [...accumulator.stored, {
290
+ envelope: {
291
+ type: event.type,
292
+ at: event.at,
293
+ state: accumulator.state.currentStateMachineState,
294
+ },
295
+ payload: toPayload(event),
296
+ }],
297
+ }), {
298
+ state: startState,
299
+ stored: [],
300
+ });
301
+ return stored;
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
+ };
299
313
  }
300
314
  verifyIdentity(sessionId, workflow) {
301
315
  const transcriptPath = workflow.getTranscriptPath();
@@ -304,14 +318,14 @@ export class WorkflowEngine {
304
318
  const expectedPrefix = getExpectedPrefix(state, registry);
305
319
  const pattern = buildPrefixPattern(registry);
306
320
  const messages = this.engineDeps.transcriptReader.readMessages(transcriptPath);
307
- const result = checkIdentity(messages, pattern);
308
- this.engineDeps.store.appendEvents(sessionId, [{
321
+ const identityCheckResult = checkIdentity(messages, pattern);
322
+ this.engineDeps.store.appendEvents(sessionId, this.wrapEvents([{
309
323
  type: 'identity-verified',
310
324
  at: this.engineDeps.now(),
311
- status: result.status,
325
+ status: identityCheckResult.status,
312
326
  transcriptPath,
313
- }]);
314
- if (result.status === 'lost') {
327
+ }], workflow.getState()));
328
+ if (identityCheckResult.status === 'lost') {
315
329
  return `You forgot. Next message MUST begin with: ${expectedPrefix}`;
316
330
  }
317
331
  return undefined;
@@ -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
+ }
@@ -1,4 +1,6 @@
1
1
  export declare const SEPARATOR = "----------------------------------------------------------------";
2
+ export declare const PLATFORM_NOTIFICATION_FENCE = "****************************************************************";
3
+ export declare const JOURNAL_GUIDANCE: string;
2
4
  /** @riviere-role cli-output-formatter */
3
5
  export declare function formatBlock(title: string, body: string): string;
4
6
  /** @riviere-role cli-output-formatter */
@@ -1,11 +1,26 @@
1
1
  export const SEPARATOR = '----------------------------------------------------------------';
2
+ export const PLATFORM_NOTIFICATION_FENCE = '****************************************************************';
3
+ export const JOURNAL_GUIDANCE = [
4
+ PLATFORM_NOTIFICATION_FENCE,
5
+ 'PLATFORM NOTIFICATION',
6
+ '',
7
+ 'Record your progress and reasoning as you work by calling:',
8
+ ' write-journal <agent-name> "<1\u20133 sentence note>"',
9
+ '',
10
+ 'Use it for key decisions, progress milestones, and blockers.',
11
+ 'Every session should have a journal trail of the work performed.',
12
+ PLATFORM_NOTIFICATION_FENCE,
13
+ ].join('\n');
2
14
  /** @riviere-role cli-output-formatter */
3
15
  export function formatBlock(title, body) {
4
16
  return `${title}\n${SEPARATOR}\n${body}`;
5
17
  }
18
+ function appendJournalGuidance(body) {
19
+ return `${body}\n\n${JOURNAL_GUIDANCE}`;
20
+ }
6
21
  /** @riviere-role cli-output-formatter */
7
22
  export function formatTransitionSuccess(title, procedureContent, expectedPrefix) {
8
- return formatBlock(title, `${procedureContent}\n\nNext message MUST begin with: ${expectedPrefix}`);
23
+ return formatBlock(title, appendJournalGuidance(`${procedureContent}\n\nNext message MUST begin with: ${expectedPrefix}`));
9
24
  }
10
25
  /** @riviere-role cli-output-formatter */
11
26
  export function formatTransitionError(to, reason, currentProcedure, expectedPrefix) {
@@ -25,5 +40,5 @@ export function formatOperationSuccess(op, body, expectedPrefix) {
25
40
  }
26
41
  /** @riviere-role cli-output-formatter */
27
42
  export function formatInitSuccess(procedureContent, expectedPrefix) {
28
- return formatBlock('Feature team initialized', `${procedureContent}\n\nNext message MUST begin with: ${expectedPrefix}`);
43
+ return formatBlock('Feature team initialized', appendJournalGuidance(`${procedureContent}\n\nNext message MUST begin with: ${expectedPrefix}`));
29
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nt-ai-lab/deterministic-agent-workflow-engine",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {