@nt-ai-lab/deterministic-agent-workflow-engine 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.
- package/dist/index.d.ts +17 -0
- package/dist/index.js +8 -0
- package/dist/platform/domain/base-event.d.ts +13 -0
- package/dist/platform/domain/base-event.js +5 -0
- package/dist/platform/domain/bash-enforcement.d.ts +4 -0
- package/dist/platform/domain/bash-enforcement.js +24 -0
- package/dist/platform/domain/engine-events.d.ts +434 -0
- package/dist/platform/domain/engine-events.js +91 -0
- package/dist/platform/domain/identity-verification.d.ts +13 -0
- package/dist/platform/domain/identity-verification.js +19 -0
- package/dist/platform/domain/precondition-result.d.ts +9 -0
- package/dist/platform/domain/precondition-result.js +5 -0
- package/dist/platform/domain/repository-tracking-events.d.ts +86 -0
- package/dist/platform/domain/repository-tracking-events.js +21 -0
- package/dist/platform/domain/workflow-engine-support.d.ts +13 -0
- package/dist/platform/domain/workflow-engine-support.js +47 -0
- package/dist/platform/domain/workflow-engine-types.d.ts +57 -0
- package/dist/platform/domain/workflow-engine-types.js +1 -0
- package/dist/platform/domain/workflow-engine.d.ts +23 -0
- package/dist/platform/domain/workflow-engine.js +325 -0
- package/dist/platform/domain/workflow-registry.d.ts +41 -0
- package/dist/platform/domain/workflow-registry.js +1 -0
- package/dist/platform/domain/workflow-state.d.ts +8 -0
- package/dist/platform/domain/workflow-state.js +7 -0
- package/dist/platform/infra/cli/presentation/output-guidance.d.ts +15 -0
- package/dist/platform/infra/cli/presentation/output-guidance.js +29 -0
- package/dist/platform/infra/external-clients/transcript/transcript-reader.d.ts +9 -0
- package/dist/platform/infra/external-clients/transcript/transcript-reader.js +1 -0
- package/package.json +19 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
declare const issueRecordedSchema: z.ZodObject<{
|
|
3
|
+
type: z.ZodLiteral<"issue-recorded">;
|
|
4
|
+
at: z.ZodString;
|
|
5
|
+
issueNumber: z.ZodNumber;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
type: "issue-recorded";
|
|
8
|
+
at: string;
|
|
9
|
+
issueNumber: number;
|
|
10
|
+
}, {
|
|
11
|
+
type: "issue-recorded";
|
|
12
|
+
at: string;
|
|
13
|
+
issueNumber: number;
|
|
14
|
+
}>;
|
|
15
|
+
declare const branchRecordedSchema: z.ZodObject<{
|
|
16
|
+
type: z.ZodLiteral<"branch-recorded">;
|
|
17
|
+
at: z.ZodString;
|
|
18
|
+
branch: z.ZodString;
|
|
19
|
+
}, "strip", z.ZodTypeAny, {
|
|
20
|
+
type: "branch-recorded";
|
|
21
|
+
at: string;
|
|
22
|
+
branch: string;
|
|
23
|
+
}, {
|
|
24
|
+
type: "branch-recorded";
|
|
25
|
+
at: string;
|
|
26
|
+
branch: string;
|
|
27
|
+
}>;
|
|
28
|
+
declare const prRecordedSchema: z.ZodObject<{
|
|
29
|
+
type: z.ZodLiteral<"pr-recorded">;
|
|
30
|
+
at: z.ZodString;
|
|
31
|
+
prNumber: z.ZodNumber;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
type: "pr-recorded";
|
|
34
|
+
at: string;
|
|
35
|
+
prNumber: number;
|
|
36
|
+
}, {
|
|
37
|
+
type: "pr-recorded";
|
|
38
|
+
at: string;
|
|
39
|
+
prNumber: number;
|
|
40
|
+
}>;
|
|
41
|
+
export declare const repositoryMetadataEventSchema: z.ZodDiscriminatedUnion<"type", [z.ZodObject<{
|
|
42
|
+
type: z.ZodLiteral<"issue-recorded">;
|
|
43
|
+
at: z.ZodString;
|
|
44
|
+
issueNumber: z.ZodNumber;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
type: "issue-recorded";
|
|
47
|
+
at: string;
|
|
48
|
+
issueNumber: number;
|
|
49
|
+
}, {
|
|
50
|
+
type: "issue-recorded";
|
|
51
|
+
at: string;
|
|
52
|
+
issueNumber: number;
|
|
53
|
+
}>, z.ZodObject<{
|
|
54
|
+
type: z.ZodLiteral<"branch-recorded">;
|
|
55
|
+
at: z.ZodString;
|
|
56
|
+
branch: z.ZodString;
|
|
57
|
+
}, "strip", z.ZodTypeAny, {
|
|
58
|
+
type: "branch-recorded";
|
|
59
|
+
at: string;
|
|
60
|
+
branch: string;
|
|
61
|
+
}, {
|
|
62
|
+
type: "branch-recorded";
|
|
63
|
+
at: string;
|
|
64
|
+
branch: string;
|
|
65
|
+
}>, z.ZodObject<{
|
|
66
|
+
type: z.ZodLiteral<"pr-recorded">;
|
|
67
|
+
at: z.ZodString;
|
|
68
|
+
prNumber: z.ZodNumber;
|
|
69
|
+
}, "strip", z.ZodTypeAny, {
|
|
70
|
+
type: "pr-recorded";
|
|
71
|
+
at: string;
|
|
72
|
+
prNumber: number;
|
|
73
|
+
}, {
|
|
74
|
+
type: "pr-recorded";
|
|
75
|
+
at: string;
|
|
76
|
+
prNumber: number;
|
|
77
|
+
}>]>;
|
|
78
|
+
/** @riviere-role value-object */
|
|
79
|
+
export type DomainMetadataEvent = z.infer<typeof repositoryMetadataEventSchema>;
|
|
80
|
+
/** @riviere-role value-object */
|
|
81
|
+
export type IssueRecordedEvent = z.infer<typeof issueRecordedSchema>;
|
|
82
|
+
/** @riviere-role value-object */
|
|
83
|
+
export type BranchRecordedEvent = z.infer<typeof branchRecordedSchema>;
|
|
84
|
+
/** @riviere-role value-object */
|
|
85
|
+
export type PrRecordedEvent = z.infer<typeof prRecordedSchema>;
|
|
86
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const issueRecordedSchema = z.object({
|
|
3
|
+
type: z.literal('issue-recorded'),
|
|
4
|
+
at: z.string(),
|
|
5
|
+
issueNumber: z.number(),
|
|
6
|
+
});
|
|
7
|
+
const branchRecordedSchema = z.object({
|
|
8
|
+
type: z.literal('branch-recorded'),
|
|
9
|
+
at: z.string(),
|
|
10
|
+
branch: z.string(),
|
|
11
|
+
});
|
|
12
|
+
const prRecordedSchema = z.object({
|
|
13
|
+
type: z.literal('pr-recorded'),
|
|
14
|
+
at: z.string(),
|
|
15
|
+
prNumber: z.number(),
|
|
16
|
+
});
|
|
17
|
+
export const repositoryMetadataEventSchema = z.discriminatedUnion('type', [
|
|
18
|
+
issueRecordedSchema,
|
|
19
|
+
branchRecordedSchema,
|
|
20
|
+
prRecordedSchema,
|
|
21
|
+
]);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { BaseEvent } from './base-event';
|
|
2
|
+
import type { WorkflowEngineDeps } from './workflow-engine-types';
|
|
3
|
+
import type { WorkflowRegistry } from './workflow-registry';
|
|
4
|
+
/** @riviere-role domain-service */
|
|
5
|
+
export declare function buildPrefixPattern<TStateName extends string, TState>(registry: WorkflowRegistry<TState, TStateName, string>): RegExp;
|
|
6
|
+
/** @riviere-role domain-service */
|
|
7
|
+
export declare function getExpectedPrefix<TStateName extends string, TState, TOperation extends string>(stateName: TStateName, registry: WorkflowRegistry<TState, TStateName, TOperation>): string;
|
|
8
|
+
/** @riviere-role domain-service */
|
|
9
|
+
export declare function buildProcedurePath<TStateName extends string>(engineDeps: WorkflowEngineDeps, stateName: TStateName): string;
|
|
10
|
+
/** @riviere-role domain-service */
|
|
11
|
+
export declare function readProcedure<TStateName extends string>(engineDeps: WorkflowEngineDeps, stateName: TStateName): string;
|
|
12
|
+
/** @riviere-role domain-service */
|
|
13
|
+
export declare function enrichSessionStartedEvents<TStateName extends string>(engineDeps: WorkflowEngineDeps, events: readonly BaseEvent[], transcriptPath: string, repository: string, currentState: TStateName, states: readonly string[]): readonly BaseEvent[];
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** @riviere-role domain-service */
|
|
2
|
+
export function buildPrefixPattern(registry) {
|
|
3
|
+
const prefixes = [];
|
|
4
|
+
for (const stateName in registry) {
|
|
5
|
+
const definition = registry[stateName];
|
|
6
|
+
prefixes.push(`${definition.emoji} ${stateName}`);
|
|
7
|
+
}
|
|
8
|
+
return new RegExp(`^(${prefixes.join('|')})`);
|
|
9
|
+
}
|
|
10
|
+
/** @riviere-role domain-service */
|
|
11
|
+
export function getExpectedPrefix(stateName, registry) {
|
|
12
|
+
return `${registry[stateName].emoji} ${stateName}`;
|
|
13
|
+
}
|
|
14
|
+
/** @riviere-role domain-service */
|
|
15
|
+
export function buildProcedurePath(engineDeps, stateName) {
|
|
16
|
+
return `${engineDeps.getPluginRoot()}/states/${String(stateName).toLowerCase()}.md`;
|
|
17
|
+
}
|
|
18
|
+
/** @riviere-role domain-service */
|
|
19
|
+
export function readProcedure(engineDeps, stateName) {
|
|
20
|
+
return engineDeps.readFile(buildProcedurePath(engineDeps, stateName));
|
|
21
|
+
}
|
|
22
|
+
/** @riviere-role domain-service */
|
|
23
|
+
export function enrichSessionStartedEvents(engineDeps, events, transcriptPath, repository, currentState, states) {
|
|
24
|
+
const enriched = events.map((event) => {
|
|
25
|
+
if (event.type !== 'session-started') {
|
|
26
|
+
return event;
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
...event,
|
|
30
|
+
transcriptPath,
|
|
31
|
+
repository,
|
|
32
|
+
currentState,
|
|
33
|
+
states: [...states],
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
if (enriched.some((event) => event.type === 'session-started')) {
|
|
37
|
+
return enriched;
|
|
38
|
+
}
|
|
39
|
+
return [{
|
|
40
|
+
type: 'session-started',
|
|
41
|
+
at: engineDeps.now(),
|
|
42
|
+
transcriptPath,
|
|
43
|
+
repository,
|
|
44
|
+
currentState,
|
|
45
|
+
states: [...states],
|
|
46
|
+
}, ...enriched];
|
|
47
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ZodType } from 'zod';
|
|
2
|
+
import type { BaseEvent } from './base-event';
|
|
3
|
+
import type { PreconditionResult } from './precondition-result';
|
|
4
|
+
import type { TranscriptReader } from '../infra/external-clients/transcript/transcript-reader';
|
|
5
|
+
import type { BaseWorkflowState } from './workflow-state';
|
|
6
|
+
import type { TransitionContext, WorkflowRegistry } from './workflow-registry';
|
|
7
|
+
/** @riviere-role value-object */
|
|
8
|
+
export type EngineResult = {
|
|
9
|
+
readonly type: 'success';
|
|
10
|
+
readonly output: string;
|
|
11
|
+
} | {
|
|
12
|
+
readonly type: 'blocked';
|
|
13
|
+
readonly output: string;
|
|
14
|
+
} | {
|
|
15
|
+
readonly type: 'error';
|
|
16
|
+
readonly output: string;
|
|
17
|
+
};
|
|
18
|
+
/** @riviere-role value-object */
|
|
19
|
+
export interface RehydratableWorkflow<TState extends BaseWorkflowState> {
|
|
20
|
+
getState(): TState;
|
|
21
|
+
appendEvent(event: BaseEvent): void;
|
|
22
|
+
getPendingEvents(): readonly BaseEvent[];
|
|
23
|
+
startSession(transcriptPath: string, repository: string | undefined): void;
|
|
24
|
+
getTranscriptPath(): string;
|
|
25
|
+
registerAgent(agentType: string, agentId: string): PreconditionResult;
|
|
26
|
+
handleTeammateIdle(agentName: string): PreconditionResult;
|
|
27
|
+
}
|
|
28
|
+
/** @riviere-role value-object */
|
|
29
|
+
export interface WorkflowDefinition<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> {
|
|
30
|
+
fold(state: TState, event: BaseEvent): TState;
|
|
31
|
+
buildWorkflow(state: TState, deps: TDeps): TWorkflow;
|
|
32
|
+
stateSchema: ZodType<TStateName>;
|
|
33
|
+
initialState(): TState;
|
|
34
|
+
getRegistry(): WorkflowRegistry<TState, TStateName, TOperation>;
|
|
35
|
+
buildTransitionContext(state: TState, from: TStateName, to: TStateName, deps: TDeps): TransitionContext<TState, TStateName>;
|
|
36
|
+
getOperationBody?(op: string, state: TState): string;
|
|
37
|
+
getTransitionTitle?(to: TStateName, state: TState): string;
|
|
38
|
+
buildTransitionEvent?(from: TStateName, to: TStateName, stateBefore: TState, stateAfter: TState, now: string): BaseEvent;
|
|
39
|
+
}
|
|
40
|
+
/** @riviere-role value-object */
|
|
41
|
+
export interface WorkflowEventStore {
|
|
42
|
+
readEvents(sessionId: string): readonly BaseEvent[];
|
|
43
|
+
appendEvents(sessionId: string, events: readonly BaseEvent[]): void;
|
|
44
|
+
sessionExists(sessionId: string): boolean;
|
|
45
|
+
hasSessionStarted(sessionId: string): boolean;
|
|
46
|
+
}
|
|
47
|
+
/** @riviere-role value-object */
|
|
48
|
+
export type WorkflowEngineDeps = {
|
|
49
|
+
readonly store: WorkflowEventStore;
|
|
50
|
+
readonly getPluginRoot: () => string;
|
|
51
|
+
readonly getEnvFilePath: () => string;
|
|
52
|
+
readonly getRepositoryName?: () => string | undefined;
|
|
53
|
+
readonly readFile: (path: string) => string;
|
|
54
|
+
readonly appendToFile: (filePath: string, content: string) => void;
|
|
55
|
+
readonly now: () => string;
|
|
56
|
+
readonly transcriptReader: TranscriptReader;
|
|
57
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { PreconditionResult } from './precondition-result';
|
|
2
|
+
import type { BashForbiddenConfig } from './workflow-registry';
|
|
3
|
+
import type { EngineResult, RehydratableWorkflow, WorkflowDefinition, WorkflowEngineDeps } from './workflow-engine-types';
|
|
4
|
+
import type { BaseWorkflowState } from './workflow-state';
|
|
5
|
+
/** @riviere-role domain-service */
|
|
6
|
+
export declare class WorkflowEngine<TWorkflow extends RehydratableWorkflow<TState>, TState extends BaseWorkflowState<TStateName>, TDeps, TStateName extends string = string, TOperation extends string = string> {
|
|
7
|
+
private readonly factory;
|
|
8
|
+
private readonly engineDeps;
|
|
9
|
+
private readonly workflowDeps;
|
|
10
|
+
constructor(factory: WorkflowDefinition<TWorkflow, TState, TDeps, TStateName, TOperation>, engineDeps: WorkflowEngineDeps, workflowDeps: TDeps);
|
|
11
|
+
startSession(sessionId: string, transcriptPath: string, repository?: string): EngineResult;
|
|
12
|
+
transaction(sessionId: string, op: string, fn: (workflow: TWorkflow) => PreconditionResult): EngineResult;
|
|
13
|
+
transition(sessionId: string, target: TStateName): EngineResult;
|
|
14
|
+
checkBash(sessionId: string, toolName: string, command: string, bashForbidden: BashForbiddenConfig): EngineResult;
|
|
15
|
+
checkWrite(sessionId: string, toolName: string, filePath: string, isWriteAllowed: (filePath: string, state: TState) => boolean): EngineResult;
|
|
16
|
+
persistSessionId(sessionId: string): void;
|
|
17
|
+
hasSession(sessionId: string): boolean;
|
|
18
|
+
hasSessionStarted(sessionId: string): boolean;
|
|
19
|
+
private requireSession;
|
|
20
|
+
private rehydrateFromEvents;
|
|
21
|
+
private persistEvents;
|
|
22
|
+
private verifyIdentity;
|
|
23
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { checkBashCommand } from './bash-enforcement.js';
|
|
2
|
+
import { checkIdentity } from './identity-verification.js';
|
|
3
|
+
import { buildPrefixPattern, buildProcedurePath, enrichSessionStartedEvents, getExpectedPrefix, readProcedure, } from './workflow-engine-support.js';
|
|
4
|
+
import { formatIllegalTransitionError, formatInitSuccess, formatOperationGateError, formatOperationSuccess, formatTransitionError, formatTransitionSuccess, } from '../infra/cli/presentation/output-guidance.js';
|
|
5
|
+
import { WorkflowStateError } from './workflow-state.js';
|
|
6
|
+
/** @riviere-role domain-service */
|
|
7
|
+
export class WorkflowEngine {
|
|
8
|
+
factory;
|
|
9
|
+
engineDeps;
|
|
10
|
+
workflowDeps;
|
|
11
|
+
constructor(factory, engineDeps, workflowDeps) {
|
|
12
|
+
this.factory = factory;
|
|
13
|
+
this.engineDeps = engineDeps;
|
|
14
|
+
this.workflowDeps = workflowDeps;
|
|
15
|
+
}
|
|
16
|
+
startSession(sessionId, transcriptPath, repository) {
|
|
17
|
+
if (this.engineDeps.store.hasSessionStarted(sessionId)) {
|
|
18
|
+
return {
|
|
19
|
+
type: 'success',
|
|
20
|
+
output: ''
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const initialState = this.factory.initialState();
|
|
24
|
+
const workflow = this.factory.buildWorkflow(initialState, this.workflowDeps);
|
|
25
|
+
const resolvedRepository = repository ?? this.engineDeps.getRepositoryName?.();
|
|
26
|
+
if (resolvedRepository === undefined || resolvedRepository === '') {
|
|
27
|
+
return {
|
|
28
|
+
type: 'error',
|
|
29
|
+
output: 'Could not determine repository name for session-started event.'
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
workflow.startSession(transcriptPath, resolvedRepository);
|
|
33
|
+
const registry = this.factory.getRegistry();
|
|
34
|
+
const stateNames = Object.keys(registry);
|
|
35
|
+
const pendingEvents = enrichSessionStartedEvents(this.engineDeps, workflow.getPendingEvents(), transcriptPath, resolvedRepository, initialState.currentStateMachineState, stateNames);
|
|
36
|
+
this.engineDeps.store.appendEvents(sessionId, pendingEvents);
|
|
37
|
+
const procedureContent = this.engineDeps.readFile(buildProcedurePath(this.engineDeps, initialState.currentStateMachineState));
|
|
38
|
+
const expectedPrefix = getExpectedPrefix(initialState.currentStateMachineState, registry);
|
|
39
|
+
return {
|
|
40
|
+
type: 'success',
|
|
41
|
+
output: formatInitSuccess(procedureContent, expectedPrefix)
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
transaction(sessionId, op, fn) {
|
|
45
|
+
this.requireSession(sessionId);
|
|
46
|
+
const workflow = this.rehydrateFromEvents(sessionId);
|
|
47
|
+
const registry = this.factory.getRegistry();
|
|
48
|
+
const identityResult = this.verifyIdentity(sessionId, workflow);
|
|
49
|
+
if (identityResult !== undefined) {
|
|
50
|
+
this.persistEvents(sessionId, workflow);
|
|
51
|
+
const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
|
|
52
|
+
return {
|
|
53
|
+
type: 'blocked',
|
|
54
|
+
output: formatOperationGateError(op, identityResult, currentPrefix)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
const result = fn(workflow);
|
|
58
|
+
this.persistEvents(sessionId, workflow);
|
|
59
|
+
const currentPrefix = getExpectedPrefix(workflow.getState().currentStateMachineState, registry);
|
|
60
|
+
if (!result.pass) {
|
|
61
|
+
return {
|
|
62
|
+
type: 'blocked',
|
|
63
|
+
output: formatOperationGateError(op, result.reason, currentPrefix)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
const body = this.factory.getOperationBody?.(op, workflow.getState()) ?? op;
|
|
67
|
+
return {
|
|
68
|
+
type: 'success',
|
|
69
|
+
output: formatOperationSuccess(op, body, currentPrefix)
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
transition(sessionId, target) {
|
|
73
|
+
this.requireSession(sessionId);
|
|
74
|
+
const workflow = this.rehydrateFromEvents(sessionId);
|
|
75
|
+
const state = workflow.getState();
|
|
76
|
+
const currentStateName = state.currentStateMachineState;
|
|
77
|
+
const registry = this.factory.getRegistry();
|
|
78
|
+
const identityResult = this.verifyIdentity(sessionId, workflow);
|
|
79
|
+
if (identityResult !== undefined) {
|
|
80
|
+
this.persistEvents(sessionId, workflow);
|
|
81
|
+
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
82
|
+
return {
|
|
83
|
+
type: 'blocked',
|
|
84
|
+
output: formatOperationGateError('transition', identityResult, currentPrefix)
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
const currentDef = registry[currentStateName];
|
|
88
|
+
if (!currentDef.canTransitionTo.includes(target)) {
|
|
89
|
+
const legalTargets = currentDef.canTransitionTo;
|
|
90
|
+
const reason = `Illegal transition ${currentStateName} -> ${target}. Legal targets from ${currentStateName}: [${legalTargets.join(', ') || 'none'}].`;
|
|
91
|
+
const currentProcedure = readProcedure(this.engineDeps, workflow.getState().currentStateMachineState);
|
|
92
|
+
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
93
|
+
return {
|
|
94
|
+
type: 'blocked',
|
|
95
|
+
output: formatIllegalTransitionError(reason, currentProcedure, currentPrefix)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (target !== 'BLOCKED' && currentDef.transitionGuard) {
|
|
99
|
+
const context = this.factory.buildTransitionContext(state, currentStateName, target, this.workflowDeps);
|
|
100
|
+
const guardResult = currentDef.transitionGuard(context);
|
|
101
|
+
if (!guardResult.pass) {
|
|
102
|
+
const currentProcedure = readProcedure(this.engineDeps, workflow.getState().currentStateMachineState);
|
|
103
|
+
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
104
|
+
return {
|
|
105
|
+
type: 'blocked',
|
|
106
|
+
output: formatTransitionError(target, guardResult.reason, currentProcedure, currentPrefix),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const targetDef = registry[target];
|
|
111
|
+
const stateBefore = workflow.getState();
|
|
112
|
+
const context = this.factory.buildTransitionContext(stateBefore, currentStateName, target, this.workflowDeps);
|
|
113
|
+
const stateAfter = targetDef.onEntry ? targetDef.onEntry(stateBefore, context) : stateBefore;
|
|
114
|
+
const transitionEvent = this.factory.buildTransitionEvent
|
|
115
|
+
? this.factory.buildTransitionEvent(currentStateName, target, stateBefore, stateAfter, this.engineDeps.now())
|
|
116
|
+
: {
|
|
117
|
+
type: 'transitioned',
|
|
118
|
+
at: this.engineDeps.now(),
|
|
119
|
+
from: currentStateName,
|
|
120
|
+
to: target
|
|
121
|
+
};
|
|
122
|
+
workflow.appendEvent(transitionEvent);
|
|
123
|
+
targetDef.afterEntry?.();
|
|
124
|
+
this.persistEvents(sessionId, workflow);
|
|
125
|
+
const newState = workflow.getState();
|
|
126
|
+
const title = this.factory.getTransitionTitle?.(newState.currentStateMachineState, newState)
|
|
127
|
+
?? newState.currentStateMachineState;
|
|
128
|
+
const procedure = readProcedure(this.engineDeps, workflow.getState().currentStateMachineState);
|
|
129
|
+
const newPrefix = getExpectedPrefix(newState.currentStateMachineState, registry);
|
|
130
|
+
return {
|
|
131
|
+
type: 'success',
|
|
132
|
+
output: formatTransitionSuccess(title, procedure, newPrefix)
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
checkBash(sessionId, toolName, command, bashForbidden) {
|
|
136
|
+
this.requireSession(sessionId);
|
|
137
|
+
const workflow = this.rehydrateFromEvents(sessionId);
|
|
138
|
+
const registry = this.factory.getRegistry();
|
|
139
|
+
const currentStateName = workflow.getState().currentStateMachineState;
|
|
140
|
+
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
141
|
+
const identityResult = this.verifyIdentity(sessionId, workflow);
|
|
142
|
+
if (identityResult !== undefined) {
|
|
143
|
+
this.persistEvents(sessionId, workflow);
|
|
144
|
+
return {
|
|
145
|
+
type: 'blocked',
|
|
146
|
+
output: formatOperationGateError('bash-check', identityResult, currentPrefix)
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (toolName !== 'Bash') {
|
|
150
|
+
workflow.appendEvent({
|
|
151
|
+
type: 'bash-checked',
|
|
152
|
+
at: this.engineDeps.now(),
|
|
153
|
+
tool: toolName,
|
|
154
|
+
command,
|
|
155
|
+
allowed: true
|
|
156
|
+
});
|
|
157
|
+
this.persistEvents(sessionId, workflow);
|
|
158
|
+
return {
|
|
159
|
+
type: 'success',
|
|
160
|
+
output: ''
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
const exemptions = registry[currentStateName].allowForbidden?.bash ?? [];
|
|
164
|
+
const result = checkBashCommand(command, bashForbidden, exemptions);
|
|
165
|
+
if (!result.pass) {
|
|
166
|
+
const reason = `Bash command blocked in ${currentStateName}. ${result.reason}`;
|
|
167
|
+
workflow.appendEvent({
|
|
168
|
+
type: 'bash-checked',
|
|
169
|
+
at: this.engineDeps.now(),
|
|
170
|
+
tool: toolName,
|
|
171
|
+
command,
|
|
172
|
+
allowed: false,
|
|
173
|
+
reason,
|
|
174
|
+
});
|
|
175
|
+
this.persistEvents(sessionId, workflow);
|
|
176
|
+
return {
|
|
177
|
+
type: 'blocked',
|
|
178
|
+
output: formatOperationGateError('bash-check', reason, currentPrefix)
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
workflow.appendEvent({
|
|
182
|
+
type: 'bash-checked',
|
|
183
|
+
at: this.engineDeps.now(),
|
|
184
|
+
tool: toolName,
|
|
185
|
+
command,
|
|
186
|
+
allowed: true
|
|
187
|
+
});
|
|
188
|
+
this.persistEvents(sessionId, workflow);
|
|
189
|
+
return {
|
|
190
|
+
type: 'success',
|
|
191
|
+
output: ''
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
checkWrite(sessionId, toolName, filePath, isWriteAllowed) {
|
|
195
|
+
this.requireSession(sessionId);
|
|
196
|
+
const workflow = this.rehydrateFromEvents(sessionId);
|
|
197
|
+
const registry = this.factory.getRegistry();
|
|
198
|
+
const currentStateName = workflow.getState().currentStateMachineState;
|
|
199
|
+
const currentPrefix = getExpectedPrefix(currentStateName, registry);
|
|
200
|
+
const identityResult = this.verifyIdentity(sessionId, workflow);
|
|
201
|
+
if (identityResult !== undefined) {
|
|
202
|
+
this.persistEvents(sessionId, workflow);
|
|
203
|
+
return {
|
|
204
|
+
type: 'blocked',
|
|
205
|
+
output: formatOperationGateError('write-check', identityResult, currentPrefix)
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const writeTools = new Set(['Write', 'Edit', 'NotebookEdit']);
|
|
209
|
+
if (!writeTools.has(toolName)) {
|
|
210
|
+
workflow.appendEvent({
|
|
211
|
+
type: 'write-checked',
|
|
212
|
+
at: this.engineDeps.now(),
|
|
213
|
+
tool: toolName,
|
|
214
|
+
filePath,
|
|
215
|
+
allowed: true
|
|
216
|
+
});
|
|
217
|
+
this.persistEvents(sessionId, workflow);
|
|
218
|
+
return {
|
|
219
|
+
type: 'success',
|
|
220
|
+
output: ''
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
const storePath = `${this.engineDeps.getPluginRoot()}/workflow.db`;
|
|
224
|
+
if (filePath === storePath) {
|
|
225
|
+
workflow.appendEvent({
|
|
226
|
+
type: 'write-checked',
|
|
227
|
+
at: this.engineDeps.now(),
|
|
228
|
+
tool: toolName,
|
|
229
|
+
filePath,
|
|
230
|
+
allowed: true
|
|
231
|
+
});
|
|
232
|
+
this.persistEvents(sessionId, workflow);
|
|
233
|
+
return {
|
|
234
|
+
type: 'success',
|
|
235
|
+
output: ''
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
if (!(registry[currentStateName].forbidden?.write ?? false)) {
|
|
239
|
+
workflow.appendEvent({
|
|
240
|
+
type: 'write-checked',
|
|
241
|
+
at: this.engineDeps.now(),
|
|
242
|
+
tool: toolName,
|
|
243
|
+
filePath,
|
|
244
|
+
allowed: true
|
|
245
|
+
});
|
|
246
|
+
this.persistEvents(sessionId, workflow);
|
|
247
|
+
return {
|
|
248
|
+
type: 'success',
|
|
249
|
+
output: ''
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (!isWriteAllowed(filePath, workflow.getState())) {
|
|
253
|
+
const reason = `Write to '${filePath}' is forbidden in state ${currentStateName}`;
|
|
254
|
+
workflow.appendEvent({
|
|
255
|
+
type: 'write-checked',
|
|
256
|
+
at: this.engineDeps.now(),
|
|
257
|
+
tool: toolName,
|
|
258
|
+
filePath,
|
|
259
|
+
allowed: false,
|
|
260
|
+
reason,
|
|
261
|
+
});
|
|
262
|
+
this.persistEvents(sessionId, workflow);
|
|
263
|
+
return {
|
|
264
|
+
type: 'blocked',
|
|
265
|
+
output: formatOperationGateError('write-check', reason, currentPrefix)
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
workflow.appendEvent({
|
|
269
|
+
type: 'write-checked',
|
|
270
|
+
at: this.engineDeps.now(),
|
|
271
|
+
tool: toolName,
|
|
272
|
+
filePath,
|
|
273
|
+
allowed: true
|
|
274
|
+
});
|
|
275
|
+
this.persistEvents(sessionId, workflow);
|
|
276
|
+
return {
|
|
277
|
+
type: 'success',
|
|
278
|
+
output: ''
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
persistSessionId(sessionId) {
|
|
282
|
+
this.engineDeps.appendToFile(this.engineDeps.getEnvFilePath(), `export CLAUDE_SESSION_ID='${sessionId}'\n`);
|
|
283
|
+
}
|
|
284
|
+
hasSession(sessionId) {
|
|
285
|
+
return this.engineDeps.store.hasSessionStarted(sessionId);
|
|
286
|
+
}
|
|
287
|
+
hasSessionStarted(sessionId) {
|
|
288
|
+
return this.engineDeps.store.hasSessionStarted(sessionId);
|
|
289
|
+
}
|
|
290
|
+
requireSession(sessionId) {
|
|
291
|
+
if (!this.engineDeps.store.hasSessionStarted(sessionId)) {
|
|
292
|
+
throw new WorkflowStateError(`No session found for '${sessionId}'. Run init first.`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
rehydrateFromEvents(sessionId) {
|
|
296
|
+
const events = this.engineDeps.store.readEvents(sessionId);
|
|
297
|
+
const state = events.reduce((accumulator, event) => this.factory.fold(accumulator, event), this.factory.initialState());
|
|
298
|
+
return this.factory.buildWorkflow(state, this.workflowDeps);
|
|
299
|
+
}
|
|
300
|
+
persistEvents(sessionId, workflow) {
|
|
301
|
+
const pending = workflow.getPendingEvents();
|
|
302
|
+
if (pending.length > 0) {
|
|
303
|
+
this.engineDeps.store.appendEvents(sessionId, pending);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
verifyIdentity(sessionId, workflow) {
|
|
307
|
+
const transcriptPath = workflow.getTranscriptPath();
|
|
308
|
+
const state = workflow.getState().currentStateMachineState;
|
|
309
|
+
const registry = this.factory.getRegistry();
|
|
310
|
+
const expectedPrefix = getExpectedPrefix(state, registry);
|
|
311
|
+
const pattern = buildPrefixPattern(registry);
|
|
312
|
+
const messages = this.engineDeps.transcriptReader.readMessages(transcriptPath);
|
|
313
|
+
const result = checkIdentity(messages, pattern);
|
|
314
|
+
this.engineDeps.store.appendEvents(sessionId, [{
|
|
315
|
+
type: 'identity-verified',
|
|
316
|
+
at: this.engineDeps.now(),
|
|
317
|
+
status: result.status,
|
|
318
|
+
transcriptPath,
|
|
319
|
+
}]);
|
|
320
|
+
if (result.status === 'lost') {
|
|
321
|
+
return `You forgot. Next message MUST begin with: ${expectedPrefix}`;
|
|
322
|
+
}
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PreconditionResult } from './precondition-result';
|
|
2
|
+
/** @riviere-role value-object */
|
|
3
|
+
export type GitInfo = {
|
|
4
|
+
readonly currentBranch: string;
|
|
5
|
+
readonly workingTreeClean: boolean;
|
|
6
|
+
readonly headCommit: string;
|
|
7
|
+
readonly changedFilesVsDefault: readonly string[];
|
|
8
|
+
readonly hasCommitsVsDefault: boolean;
|
|
9
|
+
};
|
|
10
|
+
/** @riviere-role value-object */
|
|
11
|
+
export type TransitionContext<TState, TStateName extends string = string> = {
|
|
12
|
+
readonly state: TState;
|
|
13
|
+
readonly gitInfo: GitInfo;
|
|
14
|
+
readonly from: TStateName;
|
|
15
|
+
readonly to: TStateName;
|
|
16
|
+
};
|
|
17
|
+
/** @riviere-role value-object */
|
|
18
|
+
export type BashForbiddenConfig = {
|
|
19
|
+
readonly commands: readonly string[];
|
|
20
|
+
readonly flags?: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
/** @riviere-role value-object */
|
|
23
|
+
export type WorkflowStateDefinition<TState, TStateName extends string = string, TOperation extends string = string> = {
|
|
24
|
+
readonly emoji: string;
|
|
25
|
+
readonly agentInstructions: string;
|
|
26
|
+
readonly canTransitionTo: readonly TStateName[];
|
|
27
|
+
readonly allowedWorkflowOperations: readonly TOperation[];
|
|
28
|
+
readonly forbidden?: {
|
|
29
|
+
readonly write?: boolean;
|
|
30
|
+
};
|
|
31
|
+
readonly allowForbidden?: {
|
|
32
|
+
readonly bash?: readonly string[];
|
|
33
|
+
};
|
|
34
|
+
readonly transitionGuard?: (ctx: TransitionContext<TState, TStateName>) => PreconditionResult;
|
|
35
|
+
readonly onEntry?: (state: TState, ctx: TransitionContext<TState, TStateName>) => TState;
|
|
36
|
+
readonly afterEntry?: () => void;
|
|
37
|
+
};
|
|
38
|
+
/** @riviere-role value-object */
|
|
39
|
+
export type WorkflowRegistry<TState, TStateName extends string = string, TOperation extends string = string> = {
|
|
40
|
+
readonly [K in TStateName]: WorkflowStateDefinition<TState, TStateName, TOperation>;
|
|
41
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** @riviere-role domain-error */
|
|
2
|
+
export declare class WorkflowStateError extends Error {
|
|
3
|
+
constructor(message: string);
|
|
4
|
+
}
|
|
5
|
+
/** @riviere-role value-object */
|
|
6
|
+
export type BaseWorkflowState<TStateName extends string = string> = {
|
|
7
|
+
currentStateMachineState: TStateName;
|
|
8
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const SEPARATOR = "----------------------------------------------------------------";
|
|
2
|
+
/** @riviere-role cli-output-formatter */
|
|
3
|
+
export declare function formatBlock(title: string, body: string): string;
|
|
4
|
+
/** @riviere-role cli-output-formatter */
|
|
5
|
+
export declare function formatTransitionSuccess(title: string, procedureContent: string, expectedPrefix: string): string;
|
|
6
|
+
/** @riviere-role cli-output-formatter */
|
|
7
|
+
export declare function formatTransitionError(to: string, reason: string, currentProcedure: string, expectedPrefix: string): string;
|
|
8
|
+
/** @riviere-role cli-output-formatter */
|
|
9
|
+
export declare function formatIllegalTransitionError(reason: string, currentProcedure: string, expectedPrefix: string): string;
|
|
10
|
+
/** @riviere-role cli-output-formatter */
|
|
11
|
+
export declare function formatOperationGateError(op: string, reason: string, expectedPrefix: string): string;
|
|
12
|
+
/** @riviere-role cli-output-formatter */
|
|
13
|
+
export declare function formatOperationSuccess(op: string, body: string, expectedPrefix: string): string;
|
|
14
|
+
/** @riviere-role cli-output-formatter */
|
|
15
|
+
export declare function formatInitSuccess(procedureContent: string, expectedPrefix: string): string;
|