@wopr-network/defcon 1.4.0 → 1.5.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.
@@ -12,6 +12,7 @@ export type ClaimResponse = {
12
12
  invocation_id: string;
13
13
  flow: string | null;
14
14
  stage: string;
15
+ agent_role: string | null;
15
16
  prompt: string;
16
17
  context: Record<string, unknown> | null;
17
18
  worker_notice?: string;
@@ -63,6 +63,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
63
63
  timeoutMs: g.timeoutMs,
64
64
  failurePrompt: g.failurePrompt,
65
65
  timeoutPrompt: g.timeoutPrompt,
66
+ outcomes: g.outcomes,
66
67
  });
67
68
  gateNameToId.set(g.name, gate.id);
68
69
  }
@@ -86,6 +87,7 @@ async function parseSeedAndLoad(json, flowRepo, gateRepo, db) {
86
87
  for (const s of flowStates) {
87
88
  await flowRepo.addState(flow.id, {
88
89
  name: s.name,
90
+ agentRole: s.agentRole,
89
91
  modelTier: s.modelTier,
90
92
  mode: s.mode,
91
93
  promptTemplate: s.promptTemplate,
@@ -23,6 +23,7 @@ export declare const OnEnterSchema: z.ZodObject<{
23
23
  export declare const StateDefinitionSchema: z.ZodObject<{
24
24
  name: z.ZodString;
25
25
  flowName: z.ZodString;
26
+ agentRole: z.ZodOptional<z.ZodString>;
26
27
  modelTier: z.ZodOptional<z.ZodString>;
27
28
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
28
29
  passive: "passive";
@@ -42,6 +43,10 @@ export declare const CommandGateSchema: z.ZodObject<{
42
43
  timeoutMs: z.ZodOptional<z.ZodNumber>;
43
44
  failurePrompt: z.ZodOptional<z.ZodString>;
44
45
  timeoutPrompt: z.ZodOptional<z.ZodString>;
46
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
47
+ proceed: z.ZodOptional<z.ZodBoolean>;
48
+ toState: z.ZodOptional<z.ZodString>;
49
+ }, z.core.$strip>>>;
45
50
  type: z.ZodLiteral<"command">;
46
51
  command: z.ZodString;
47
52
  }, z.core.$strip>;
@@ -50,6 +55,10 @@ export declare const FunctionGateSchema: z.ZodObject<{
50
55
  timeoutMs: z.ZodOptional<z.ZodNumber>;
51
56
  failurePrompt: z.ZodOptional<z.ZodString>;
52
57
  timeoutPrompt: z.ZodOptional<z.ZodString>;
58
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
59
+ proceed: z.ZodOptional<z.ZodBoolean>;
60
+ toState: z.ZodOptional<z.ZodString>;
61
+ }, z.core.$strip>>>;
53
62
  type: z.ZodLiteral<"function">;
54
63
  functionRef: z.ZodString;
55
64
  }, z.core.$strip>;
@@ -58,6 +67,10 @@ export declare const ApiGateSchema: z.ZodObject<{
58
67
  timeoutMs: z.ZodOptional<z.ZodNumber>;
59
68
  failurePrompt: z.ZodOptional<z.ZodString>;
60
69
  timeoutPrompt: z.ZodOptional<z.ZodString>;
70
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
71
+ proceed: z.ZodOptional<z.ZodBoolean>;
72
+ toState: z.ZodOptional<z.ZodString>;
73
+ }, z.core.$strip>>>;
61
74
  type: z.ZodLiteral<"api">;
62
75
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
63
76
  }, z.core.$strip>;
@@ -66,6 +79,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
66
79
  timeoutMs: z.ZodOptional<z.ZodNumber>;
67
80
  failurePrompt: z.ZodOptional<z.ZodString>;
68
81
  timeoutPrompt: z.ZodOptional<z.ZodString>;
82
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
83
+ proceed: z.ZodOptional<z.ZodBoolean>;
84
+ toState: z.ZodOptional<z.ZodString>;
85
+ }, z.core.$strip>>>;
69
86
  type: z.ZodLiteral<"command">;
70
87
  command: z.ZodString;
71
88
  }, z.core.$strip>, z.ZodObject<{
@@ -73,6 +90,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
73
90
  timeoutMs: z.ZodOptional<z.ZodNumber>;
74
91
  failurePrompt: z.ZodOptional<z.ZodString>;
75
92
  timeoutPrompt: z.ZodOptional<z.ZodString>;
93
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
94
+ proceed: z.ZodOptional<z.ZodBoolean>;
95
+ toState: z.ZodOptional<z.ZodString>;
96
+ }, z.core.$strip>>>;
76
97
  type: z.ZodLiteral<"function">;
77
98
  functionRef: z.ZodString;
78
99
  }, z.core.$strip>, z.ZodObject<{
@@ -80,6 +101,10 @@ export declare const GateDefinitionSchema: z.ZodDiscriminatedUnion<[z.ZodObject<
80
101
  timeoutMs: z.ZodOptional<z.ZodNumber>;
81
102
  failurePrompt: z.ZodOptional<z.ZodString>;
82
103
  timeoutPrompt: z.ZodOptional<z.ZodString>;
104
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
105
+ proceed: z.ZodOptional<z.ZodBoolean>;
106
+ toState: z.ZodOptional<z.ZodString>;
107
+ }, z.core.$strip>>>;
83
108
  type: z.ZodLiteral<"api">;
84
109
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
85
110
  }, z.core.$strip>], "type">;
@@ -114,6 +139,7 @@ export declare const SeedFileSchema: z.ZodObject<{
114
139
  states: z.ZodArray<z.ZodObject<{
115
140
  name: z.ZodString;
116
141
  flowName: z.ZodString;
142
+ agentRole: z.ZodOptional<z.ZodString>;
117
143
  modelTier: z.ZodOptional<z.ZodString>;
118
144
  mode: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
119
145
  passive: "passive";
@@ -133,6 +159,10 @@ export declare const SeedFileSchema: z.ZodObject<{
133
159
  timeoutMs: z.ZodOptional<z.ZodNumber>;
134
160
  failurePrompt: z.ZodOptional<z.ZodString>;
135
161
  timeoutPrompt: z.ZodOptional<z.ZodString>;
162
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
163
+ proceed: z.ZodOptional<z.ZodBoolean>;
164
+ toState: z.ZodOptional<z.ZodString>;
165
+ }, z.core.$strip>>>;
136
166
  type: z.ZodLiteral<"command">;
137
167
  command: z.ZodString;
138
168
  }, z.core.$strip>, z.ZodObject<{
@@ -140,6 +170,10 @@ export declare const SeedFileSchema: z.ZodObject<{
140
170
  timeoutMs: z.ZodOptional<z.ZodNumber>;
141
171
  failurePrompt: z.ZodOptional<z.ZodString>;
142
172
  timeoutPrompt: z.ZodOptional<z.ZodString>;
173
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
174
+ proceed: z.ZodOptional<z.ZodBoolean>;
175
+ toState: z.ZodOptional<z.ZodString>;
176
+ }, z.core.$strip>>>;
143
177
  type: z.ZodLiteral<"function">;
144
178
  functionRef: z.ZodString;
145
179
  }, z.core.$strip>, z.ZodObject<{
@@ -147,6 +181,10 @@ export declare const SeedFileSchema: z.ZodObject<{
147
181
  timeoutMs: z.ZodOptional<z.ZodNumber>;
148
182
  failurePrompt: z.ZodOptional<z.ZodString>;
149
183
  timeoutPrompt: z.ZodOptional<z.ZodString>;
184
+ outcomes: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
185
+ proceed: z.ZodOptional<z.ZodBoolean>;
186
+ toState: z.ZodOptional<z.ZodString>;
187
+ }, z.core.$strip>>>;
150
188
  type: z.ZodLiteral<"api">;
151
189
  apiConfig: z.ZodRecord<z.ZodString, z.ZodUnknown>;
152
190
  }, z.core.$strip>], "type">>>>;
@@ -37,6 +37,7 @@ export const OnEnterSchema = z.object({
37
37
  export const StateDefinitionSchema = z.object({
38
38
  name: z.string().min(1),
39
39
  flowName: z.string().min(1),
40
+ agentRole: z.string().optional(),
40
41
  modelTier: z.string().optional(),
41
42
  mode: z.enum(["passive", "active"]).optional().default("passive"),
42
43
  promptTemplate: z
@@ -51,11 +52,16 @@ export const StateDefinitionSchema = z.object({
51
52
  retryAfterMs: z.number().int().min(0).optional(),
52
53
  });
53
54
  // Gate: discriminated union on `type`
55
+ const GateOutcomeSchema = z.object({
56
+ proceed: z.boolean().optional(),
57
+ toState: z.string().min(1).optional(),
58
+ });
54
59
  const BaseGateSchema = z.object({
55
60
  name: z.string().min(1),
56
61
  timeoutMs: z.number().int().min(1).optional(),
57
62
  failurePrompt: z.string().optional(),
58
63
  timeoutPrompt: z.string().min(1).optional(),
64
+ outcomes: z.record(z.string(), GateOutcomeSchema).optional(),
59
65
  });
60
66
  export const CommandGateSchema = BaseGateSchema.extend({
61
67
  type: z.literal("command"),
@@ -56,6 +56,13 @@ export declare class Engine {
56
56
  listDrainingWorkers(): string[];
57
57
  emit(event: import("./event-types.js").EngineEvent): Promise<void>;
58
58
  processSignal(entityId: string, signal: string, artifacts?: Artifacts, triggeringInvocationId?: string): Promise<ProcessSignalResult>;
59
+ /**
60
+ * Evaluate a gate and return a routing decision:
61
+ * - `proceed` — gate passed, continue to transition.toState
62
+ * - `redirect` — gate outcome maps to a different toState
63
+ * - `block` — gate failed or timed out; caller should return this as the signal result
64
+ */
65
+ private resolveGate;
59
66
  createEntity(flowName: string, refs?: Record<string, {
60
67
  adapter: string;
61
68
  id: string;
@@ -55,83 +55,20 @@ export class Engine {
55
55
  const transition = findTransition(flow, entity.state, signal, { entity }, true, this.logger);
56
56
  if (!transition)
57
57
  throw new ValidationError(`No transition from "${entity.state}" on signal "${signal}" in flow "${flow.name}"`);
58
- // 4. Evaluate gate if present
59
- const gatesPassed = [];
60
- if (transition.gateId) {
61
- const gate = await this.gateRepo.get(transition.gateId);
62
- if (!gate)
63
- throw new NotFoundError(`Gate "${transition.gateId}" not found`);
64
- const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
65
- if (!gateResult.passed) {
66
- // Persist gate failure into entity artifacts for retry context
67
- const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
68
- ? entity.artifacts.gate_failures
69
- : [];
70
- await this.entityRepo.updateArtifacts(entityId, {
71
- gate_failures: [
72
- ...priorFailures,
73
- {
74
- gateId: gate.id,
75
- gateName: gate.name,
76
- output: gateResult.output,
77
- failedAt: new Date().toISOString(),
78
- },
79
- ],
80
- });
81
- if (gateResult.timedOut) {
82
- await this.eventEmitter.emit({
83
- type: "gate.timedOut",
84
- entityId,
85
- gateId: gate.id,
86
- emittedAt: new Date(),
87
- });
88
- }
89
- else {
90
- await this.eventEmitter.emit({
91
- type: "gate.failed",
92
- entityId,
93
- gateId: gate.id,
94
- emittedAt: new Date(),
95
- });
96
- }
97
- let resolvedTimeoutPrompt;
98
- if (gateResult.timedOut) {
99
- const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
100
- try {
101
- const hbs = getHandlebars();
102
- const template = hbs.compile(rawTemplate);
103
- resolvedTimeoutPrompt = template({
104
- entity,
105
- flow,
106
- gate: { name: gate.name, output: gateResult.output },
107
- });
108
- }
109
- catch (err) {
110
- this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
111
- resolvedTimeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
112
- }
113
- }
114
- return {
115
- gated: true,
116
- gateTimedOut: gateResult.timedOut,
117
- gateOutput: gateResult.output,
118
- gateName: gate.name,
119
- failurePrompt: gate.failurePrompt ?? undefined,
120
- timeoutPrompt: resolvedTimeoutPrompt,
121
- gatesPassed,
122
- terminal: false,
123
- };
124
- }
125
- gatesPassed.push(gate.name);
126
- await this.eventEmitter.emit({
127
- type: "gate.passed",
128
- entityId,
129
- gateId: gate.id,
130
- emittedAt: new Date(),
131
- });
58
+ // 4. Evaluate gate returns a routing decision or a block result to return immediately.
59
+ const routing = transition.gateId
60
+ ? await this.resolveGate(transition.gateId, entity, flow)
61
+ : { kind: "proceed", gatesPassed: [] };
62
+ if (routing.kind === "block") {
63
+ return { gated: true, ...routing, terminal: false };
132
64
  }
65
+ // Gate passed or redirected — determine the actual destination.
66
+ const toState = routing.kind === "redirect" ? routing.toState : transition.toState;
67
+ const trigger = routing.kind === "redirect" ? routing.trigger : signal;
68
+ const spawnFlow = routing.kind === "redirect" ? null : transition.spawnFlow;
69
+ const { gatesPassed } = routing;
133
70
  // 5. Transition entity
134
- let updated = await this.entityRepo.transition(entityId, transition.toState, signal, artifacts);
71
+ let updated = await this.entityRepo.transition(entityId, toState, trigger, artifacts);
135
72
  // Clear gate_failures on successful transition so stale failures don't bleed into future agent prompts
136
73
  await this.entityRepo.updateArtifacts(entityId, { gate_failures: [] });
137
74
  // Keep the in-memory entity in sync so buildInvocation sees the cleared failures
@@ -142,25 +79,25 @@ export class Engine {
142
79
  entityId,
143
80
  flowId: flow.id,
144
81
  fromState: entity.state,
145
- toState: transition.toState,
146
- trigger: signal,
82
+ toState,
83
+ trigger,
147
84
  emittedAt: new Date(),
148
85
  });
149
86
  const result = {
150
- newState: transition.toState,
87
+ newState: toState,
151
88
  gatesPassed,
152
89
  gated: false,
153
90
  terminal: false,
154
91
  };
155
92
  // 6b. Execute onEnter hook if defined on the new state
156
- const newStateDef = flow.states.find((s) => s.name === transition.toState);
93
+ const newStateDef = flow.states.find((s) => s.name === toState);
157
94
  if (newStateDef?.onEnter) {
158
95
  const onEnterResult = await executeOnEnter(newStateDef.onEnter, updated, this.entityRepo);
159
96
  if (onEnterResult.skipped) {
160
97
  await this.eventEmitter.emit({
161
98
  type: "onEnter.skipped",
162
99
  entityId,
163
- state: transition.toState,
100
+ state: toState,
164
101
  emittedAt: new Date(),
165
102
  });
166
103
  }
@@ -168,20 +105,20 @@ export class Engine {
168
105
  await this.eventEmitter.emit({
169
106
  type: "onEnter.failed",
170
107
  entityId,
171
- state: transition.toState,
108
+ state: toState,
172
109
  error: onEnterResult.error,
173
110
  emittedAt: new Date(),
174
111
  });
175
112
  await this.transitionLogRepo.record({
176
113
  entityId,
177
114
  fromState: entity.state,
178
- toState: transition.toState,
179
- trigger: signal,
115
+ toState,
116
+ trigger,
180
117
  invocationId: triggeringInvocationId ?? null,
181
118
  timestamp: new Date(),
182
119
  });
183
120
  return {
184
- newState: transition.toState,
121
+ newState: toState,
185
122
  gatesPassed,
186
123
  gated: false,
187
124
  onEnterFailed: true,
@@ -193,7 +130,7 @@ export class Engine {
193
130
  await this.eventEmitter.emit({
194
131
  type: "onEnter.completed",
195
132
  entityId,
196
- state: transition.toState,
133
+ state: toState,
197
134
  artifacts: onEnterResult.artifacts ?? {},
198
135
  emittedAt: new Date(),
199
136
  });
@@ -214,15 +151,15 @@ export class Engine {
214
151
  ]);
215
152
  const enriched = { ...updated, invocations, gateResults };
216
153
  const build = await buildInvocation(newStateDef, enriched, this.adapters, flow, this.logger);
217
- const invocation = await this.invocationRepo.create(entityId, transition.toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
154
+ const invocation = await this.invocationRepo.create(entityId, toState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
218
155
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
219
- : undefined);
156
+ : undefined, newStateDef.agentRole ?? null);
220
157
  result.invocationId = invocation.id;
221
158
  await this.eventEmitter.emit({
222
159
  type: "invocation.created",
223
160
  entityId,
224
161
  invocationId: invocation.id,
225
- stage: transition.toState,
162
+ stage: toState,
226
163
  emittedAt: new Date(),
227
164
  });
228
165
  }
@@ -232,13 +169,13 @@ export class Engine {
232
169
  await this.transitionLogRepo.record({
233
170
  entityId,
234
171
  fromState: entity.state,
235
- toState: transition.toState,
236
- trigger: signal,
172
+ toState,
173
+ trigger,
237
174
  invocationId: triggeringInvocationId ?? null,
238
175
  timestamp: new Date(),
239
176
  });
240
177
  // 9. Spawn child flows
241
- const spawned = await executeSpawn(transition, updated, this.flowRepo, this.entityRepo, this.logger);
178
+ const spawned = await executeSpawn({ spawnFlow }, updated, this.flowRepo, this.entityRepo, this.logger);
242
179
  if (spawned) {
243
180
  result.spawned = [spawned.id];
244
181
  await this.eventEmitter.emit({
@@ -249,14 +186,93 @@ export class Engine {
249
186
  emittedAt: new Date(),
250
187
  });
251
188
  }
252
- // 10. Mark terminal — no invocation is created for terminal states (handled above),
253
- // but we surface terminality in the result for callers.
254
- if (isTerminal(flow, transition.toState)) {
189
+ // 10. Mark terminal
190
+ if (isTerminal(flow, toState)) {
255
191
  result.terminal = true;
256
192
  result.spawned = result.spawned ?? [];
257
193
  }
258
194
  return result;
259
195
  }
196
+ /**
197
+ * Evaluate a gate and return a routing decision:
198
+ * - `proceed` — gate passed, continue to transition.toState
199
+ * - `redirect` — gate outcome maps to a different toState
200
+ * - `block` — gate failed or timed out; caller should return this as the signal result
201
+ */
202
+ async resolveGate(gateId, entity, flow) {
203
+ const gate = await this.gateRepo.get(gateId);
204
+ if (!gate)
205
+ throw new NotFoundError(`Gate "${gateId}" not found`);
206
+ const gateResult = await evaluateGate(gate, entity, this.gateRepo, flow.gateTimeoutMs);
207
+ const namedOutcome = gateResult.outcome && gate.outcomes ? gate.outcomes[gateResult.outcome] : undefined;
208
+ if (namedOutcome?.toState) {
209
+ const outcomeLabel = gateResult.outcome ?? gate.name;
210
+ await this.eventEmitter.emit({
211
+ type: "gate.redirected",
212
+ entityId: entity.id,
213
+ gateId: gate.id,
214
+ outcome: outcomeLabel,
215
+ toState: namedOutcome.toState,
216
+ emittedAt: new Date(),
217
+ });
218
+ return {
219
+ kind: "redirect",
220
+ toState: namedOutcome.toState,
221
+ trigger: `gate:${gate.name}:${outcomeLabel}`,
222
+ gatesPassed: [gate.name],
223
+ };
224
+ }
225
+ if (namedOutcome?.proceed || (!namedOutcome && gateResult.passed)) {
226
+ await this.eventEmitter.emit({
227
+ type: "gate.passed",
228
+ entityId: entity.id,
229
+ gateId: gate.id,
230
+ emittedAt: new Date(),
231
+ });
232
+ return { kind: "proceed", gatesPassed: [gate.name] };
233
+ }
234
+ // Gate failed — persist failure context and emit event
235
+ const priorFailures = Array.isArray(entity.artifacts?.gate_failures)
236
+ ? entity.artifacts.gate_failures
237
+ : [];
238
+ await this.entityRepo.updateArtifacts(entity.id, {
239
+ gate_failures: [
240
+ ...priorFailures,
241
+ { gateId: gate.id, gateName: gate.name, output: gateResult.output, failedAt: new Date().toISOString() },
242
+ ],
243
+ });
244
+ await this.eventEmitter.emit({
245
+ type: gateResult.timedOut ? "gate.timedOut" : "gate.failed",
246
+ entityId: entity.id,
247
+ gateId: gate.id,
248
+ emittedAt: new Date(),
249
+ });
250
+ let timeoutPrompt;
251
+ if (gateResult.timedOut) {
252
+ const rawTemplate = gate.timeoutPrompt ?? flow.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
253
+ try {
254
+ const hbs = getHandlebars();
255
+ timeoutPrompt = hbs.compile(rawTemplate)({
256
+ entity,
257
+ flow,
258
+ gate: { name: gate.name, output: gateResult.output },
259
+ });
260
+ }
261
+ catch (err) {
262
+ this.logger.error("[engine] Failed to render timeoutPrompt template:", err);
263
+ timeoutPrompt = DEFAULT_TIMEOUT_PROMPT;
264
+ }
265
+ }
266
+ return {
267
+ kind: "block",
268
+ gateTimedOut: gateResult.timedOut,
269
+ gateOutput: gateResult.output,
270
+ gateName: gate.name,
271
+ failurePrompt: gate.failurePrompt ?? undefined,
272
+ timeoutPrompt,
273
+ gatesPassed: [],
274
+ };
275
+ }
260
276
  async createEntity(flowName, refs, payload) {
261
277
  const flow = await this.flowRepo.getByName(flowName);
262
278
  if (!flow)
@@ -315,7 +331,7 @@ export class Engine {
315
331
  const build = await buildInvocation(initialState, enriched, this.adapters, flow, this.logger);
316
332
  await this.invocationRepo.create(entity.id, flow.initialState, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
317
333
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
318
- : undefined);
334
+ : undefined, initialState.agentRole ?? null);
319
335
  }
320
336
  return entity;
321
337
  }
@@ -487,7 +503,7 @@ export class Engine {
487
503
  const build = await this.buildPromptForEntity(state, claimed, flow);
488
504
  const invocation = await this.invocationRepo.create(claimed.id, state.name, build.prompt, build.mode, undefined, build.systemPrompt || build.userContent
489
505
  ? { systemPrompt: build.systemPrompt, userContent: build.userContent }
490
- : undefined);
506
+ : undefined, state.agentRole ?? null);
491
507
  const entityClaimToken = worker_id ?? `agent:${role}`;
492
508
  let claimedInvocation;
493
509
  try {
@@ -68,6 +68,13 @@ export type EngineEvent = {
68
68
  entityId: string;
69
69
  gateId: string;
70
70
  emittedAt: Date;
71
+ } | {
72
+ type: "gate.redirected";
73
+ entityId: string;
74
+ gateId: string;
75
+ outcome: string;
76
+ toState: string;
77
+ emittedAt: Date;
71
78
  } | {
72
79
  type: "flow.spawned";
73
80
  entityId: string;
@@ -1,8 +1,10 @@
1
1
  import type { Logger } from "../logger.js";
2
- import type { Entity, IEntityRepository, IFlowRepository, Transition } from "../repositories/interfaces.js";
2
+ import type { Entity, IEntityRepository, IFlowRepository } from "../repositories/interfaces.js";
3
3
  /**
4
4
  * If the transition has a spawnFlow, look up that flow and create a new entity in it.
5
5
  * The spawned entity inherits the parent entity's refs.
6
6
  * Returns the spawned entity, or null if no spawn is configured.
7
7
  */
8
- export declare function executeSpawn(transition: Transition, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
8
+ export declare function executeSpawn(transition: {
9
+ spawnFlow: string | null | undefined;
10
+ }, parentEntity: Entity, flowRepo: IFlowRepository, entityRepo: IEntityRepository, logger?: Logger): Promise<Entity | null>;
@@ -3,6 +3,10 @@ export interface GateEvalResult {
3
3
  passed: boolean;
4
4
  timedOut: boolean;
5
5
  output: string;
6
+ /** Named outcome from structured JSON output, if the gate emitted one. */
7
+ outcome?: string;
8
+ /** Human-readable message from structured JSON output. */
9
+ message?: string;
6
10
  }
7
11
  export declare function resolveGateTimeout(gateTimeoutMs: number | null | undefined, flowGateTimeoutMs: number | null | undefined): number;
8
12
  /**
@@ -59,6 +59,33 @@ export async function evaluateGate(gate, entity, gateRepo, flowGateTimeoutMs) {
59
59
  passed = result.exitCode === 0;
60
60
  output = result.output;
61
61
  timedOut = result.timedOut;
62
+ // Parse structured JSON outcome from the last non-empty output line.
63
+ // Gate scripts can emit { outcome: string, message?: string } as their final line
64
+ // to enable named outcome routing (e.g. "blocked" → toState: "fixing").
65
+ const lastLine = result.output
66
+ .split("\n")
67
+ .map((l) => l.trim())
68
+ .filter(Boolean)
69
+ .at(-1);
70
+ if (lastLine?.startsWith("{")) {
71
+ try {
72
+ const parsed = JSON.parse(lastLine);
73
+ if (typeof parsed.outcome === "string") {
74
+ const outcomeResult = {
75
+ passed,
76
+ timedOut,
77
+ output: result.output,
78
+ outcome: parsed.outcome,
79
+ message: typeof parsed.message === "string" ? parsed.message : undefined,
80
+ };
81
+ await gateRepo.record(entity.id, gate.id, passed, result.output);
82
+ return outcomeResult;
83
+ }
84
+ }
85
+ catch {
86
+ // Not JSON — treat output as plain text, fall through to normal path
87
+ }
88
+ }
62
89
  }
63
90
  else if (gate.type === "function") {
64
91
  try {
@@ -104,7 +104,7 @@ export class ActiveRunner {
104
104
  await this.entityRepo.updateArtifacts(invocation.entityId, { stuck: true, stuckAt: new Date().toISOString() });
105
105
  return;
106
106
  }
107
- await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, { ...(invocation.context ?? {}), retryCount: retryCount + 1 });
107
+ await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, { ...(invocation.context ?? {}), retryCount: retryCount + 1 }, invocation.agentRole);
108
108
  return;
109
109
  }
110
110
  // Gate timed out — re-queue after backoff instead of failing.
@@ -113,7 +113,7 @@ export class ActiveRunner {
113
113
  if (result.gated && result.gateTimedOut) {
114
114
  const retryAfterMs = 30000;
115
115
  this.logger.info(`[active-runner] gate timed out for entity ${invocation.entityId}, re-queuing after ${retryAfterMs}ms`);
116
- await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, invocation.context ?? undefined);
116
+ await this.invocationRepo.create(invocation.entityId, invocation.stage, invocation.prompt, invocation.mode, undefined, invocation.context ?? undefined, invocation.agentRole);
117
117
  await sleep(retryAfterMs, signal);
118
118
  return;
119
119
  }
@@ -203,6 +203,7 @@ export async function handleFlowClaim(deps, args) {
203
203
  invocation_id: claimed.id,
204
204
  flow: flow?.name ?? null,
205
205
  stage: claimed.stage,
206
+ agent_role: claimed.agentRole || null,
206
207
  prompt: claimed.prompt,
207
208
  context: claimed.context,
208
209
  });
@@ -260,7 +261,7 @@ export async function handleFlowReport(deps, args) {
260
261
  // would regress the entity back to a stale state.
261
262
  const entityAfter = await deps.entities.get(entityId).catch(() => null);
262
263
  if (!entityAfter || entityAfter.state === activeInvocation.stage) {
263
- await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
264
+ await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined, activeInvocation.agentRole);
264
265
  }
265
266
  return errorResult(message);
266
267
  }
@@ -286,7 +287,7 @@ export async function handleFlowReport(deps, args) {
286
287
  // check_back) finds an active invocation without requiring a round-trip through
287
288
  // flow.claim. Workers on the "waiting" path will re-claim via flow.claim as usual.
288
289
  if (result.gated) {
289
- const replacement = await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
290
+ const replacement = await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined, activeInvocation.agentRole);
290
291
  const claimedBy = activeInvocation.claimedBy;
291
292
  if (claimedBy) {
292
293
  await deps.invocations.claim(replacement.id, claimedBy).catch(() => {
@@ -556,7 +556,7 @@ async function handleFlowReport(deps, args) {
556
556
  const flow = currentEntity ? await deps.flows.get(currentEntity.flowId) : null;
557
557
  const entityIsTerminal = currentEntity && flow ? isTerminal(flow, currentEntity.state) : false;
558
558
  if (!entityIsTerminal) {
559
- const replacement = await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
559
+ const replacement = await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined, activeInvocation.agentRole);
560
560
  // Claim the replacement for the same worker so it can retry immediately.
561
561
  if (worker_id && replacement) {
562
562
  try {
@@ -590,7 +590,7 @@ async function handleFlowReport(deps, args) {
590
590
  // Gate blocked — create a replacement unclaimed invocation so the entity
591
591
  // can be reclaimed; without it the entity would be permanently orphaned.
592
592
  if (result.gated) {
593
- await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined);
593
+ await deps.invocations.create(entityId, activeInvocation.stage, activeInvocation.prompt, activeInvocation.mode, undefined, activeInvocation.context ?? undefined, activeInvocation.agentRole);
594
594
  if (result.gateTimedOut) {
595
595
  const renderedPrompt = result.timeoutPrompt ?? DEFAULT_TIMEOUT_PROMPT;
596
596
  return jsonResult({
@@ -9,6 +9,7 @@ function rowToState(r) {
9
9
  id: r.id,
10
10
  flowId: r.flowId,
11
11
  name: r.name,
12
+ agentRole: r.agentRole ?? null,
12
13
  modelTier: r.modelTier ?? null,
13
14
  mode: (r.mode ?? "passive"),
14
15
  promptTemplate: r.promptTemplate ?? null,
@@ -167,7 +168,7 @@ export class DrizzleFlowRepository {
167
168
  id,
168
169
  flowId,
169
170
  name: state.name,
170
- agentRole: null,
171
+ agentRole: state.agentRole || null,
171
172
  modelTier: state.modelTier ?? null,
172
173
  mode: state.mode ?? "passive",
173
174
  promptTemplate: state.promptTemplate ?? null,
@@ -188,6 +189,8 @@ export class DrizzleFlowRepository {
188
189
  const updateValues = {};
189
190
  if (changes.name !== undefined)
190
191
  updateValues.name = changes.name;
192
+ if (changes.agentRole !== undefined)
193
+ updateValues.agentRole = changes.agentRole;
191
194
  if (changes.modelTier !== undefined)
192
195
  updateValues.modelTier = changes.modelTier;
193
196
  if (changes.mode !== undefined)
@@ -329,6 +332,7 @@ export class DrizzleFlowRepository {
329
332
  id: s.id,
330
333
  flowId,
331
334
  name: s.name,
335
+ agentRole: s.agentRole || null,
332
336
  modelTier: s.modelTier,
333
337
  mode: s.mode ?? "passive",
334
338
  promptTemplate: s.promptTemplate,
@@ -13,6 +13,7 @@ function toGate(row) {
13
13
  timeoutMs: row.timeoutMs ?? null,
14
14
  failurePrompt: row.failurePrompt ?? null,
15
15
  timeoutPrompt: row.timeoutPrompt ?? null,
16
+ outcomes: row.outcomes ?? null,
16
17
  };
17
18
  }
18
19
  function toGateResult(row) {
@@ -42,6 +43,7 @@ export class DrizzleGateRepository {
42
43
  ...(gate.timeoutMs != null ? { timeoutMs: gate.timeoutMs } : {}),
43
44
  failurePrompt: gate.failurePrompt ?? null,
44
45
  timeoutPrompt: gate.timeoutPrompt ?? null,
46
+ outcomes: gate.outcomes ?? null,
45
47
  };
46
48
  this.db.insert(gateDefinitions).values(values).run();
47
49
  const row = this.db.select().from(gateDefinitions).where(eq(gateDefinitions.id, id)).get();
@@ -5,7 +5,7 @@ type Db = BetterSQLite3Database<typeof schema>;
5
5
  export declare class DrizzleInvocationRepository implements IInvocationRepository {
6
6
  private db;
7
7
  constructor(db: Db);
8
- create(entityId: string, stage: string, prompt: string, mode: Mode, ttlMs?: number, context?: Record<string, unknown>): Promise<Invocation>;
8
+ create(entityId: string, stage: string, prompt: string, mode: Mode, ttlMs: number | undefined, context: Record<string, unknown> | undefined, agentRole: string | null): Promise<Invocation>;
9
9
  get(id: string): Promise<Invocation | null>;
10
10
  claim(invocationId: string, agentId: string): Promise<Invocation | null>;
11
11
  complete(id: string, signal: string, artifacts?: Artifacts): Promise<Invocation>;
@@ -6,6 +6,7 @@ function toInvocation(row) {
6
6
  id: row.id,
7
7
  entityId: row.entityId,
8
8
  stage: row.stage,
9
+ agentRole: row.agentRole ?? null,
9
10
  mode: row.mode,
10
11
  prompt: row.prompt,
11
12
  context: row.context,
@@ -25,7 +26,7 @@ export class DrizzleInvocationRepository {
25
26
  constructor(db) {
26
27
  this.db = db;
27
28
  }
28
- async create(entityId, stage, prompt, mode, ttlMs, context) {
29
+ async create(entityId, stage, prompt, mode, ttlMs, context, agentRole) {
29
30
  const id = crypto.randomUUID();
30
31
  this.db
31
32
  .insert(invocations)
@@ -33,6 +34,7 @@ export class DrizzleInvocationRepository {
33
34
  id,
34
35
  entityId,
35
36
  stage,
37
+ agentRole: agentRole || null,
36
38
  prompt,
37
39
  mode,
38
40
  ttlMs: ttlMs ?? 1800000,
@@ -689,6 +689,23 @@ export declare const gateDefinitions: import("drizzle-orm/sqlite-core").SQLiteTa
689
689
  }, {}, {
690
690
  length: number | undefined;
691
691
  }>;
692
+ outcomes: import("drizzle-orm/sqlite-core").SQLiteColumn<{
693
+ name: "outcomes";
694
+ tableName: "gate_definitions";
695
+ dataType: "json";
696
+ columnType: "SQLiteTextJson";
697
+ data: unknown;
698
+ driverParam: string;
699
+ notNull: false;
700
+ hasDefault: false;
701
+ isPrimaryKey: false;
702
+ isAutoincrement: false;
703
+ hasRuntimeDefault: false;
704
+ enumValues: undefined;
705
+ baseColumn: never;
706
+ identity: undefined;
707
+ generated: undefined;
708
+ }, {}, {}>;
692
709
  };
693
710
  dialect: "sqlite";
694
711
  }>;
@@ -46,6 +46,7 @@ export const gateDefinitions = sqliteTable("gate_definitions", {
46
46
  timeoutMs: integer("timeout_ms"),
47
47
  failurePrompt: text("failure_prompt"),
48
48
  timeoutPrompt: text("timeout_prompt"),
49
+ outcomes: text("outcomes", { mode: "json" }),
49
50
  });
50
51
  export const transitionRules = sqliteTable("transition_rules", {
51
52
  id: text("id").primaryKey(),
@@ -36,6 +36,7 @@ export interface Invocation {
36
36
  id: string;
37
37
  entityId: string;
38
38
  stage: string;
39
+ agentRole: string | null;
39
40
  mode: Mode;
40
41
  prompt: string;
41
42
  context: Record<string, unknown> | null;
@@ -82,6 +83,8 @@ export interface State {
82
83
  id: string;
83
84
  flowId: string;
84
85
  name: string;
86
+ /** Agent type identifier — maps to an agent MD file (e.g. "wopr-architect" → ~/.claude/agents/wopr-architect.md). */
87
+ agentRole: string | null;
85
88
  modelTier: string | null;
86
89
  mode: Mode;
87
90
  promptTemplate: string | null;
@@ -115,6 +118,13 @@ export interface Gate {
115
118
  timeoutMs: number | null;
116
119
  failurePrompt: string | null;
117
120
  timeoutPrompt: string | null;
121
+ /** Named outcome map from structured gate output. Keys are outcome names; values declare
122
+ * where the entity goes. `proceed: true` means the original transition continues.
123
+ * `toState` redirects to a different state. */
124
+ outcomes: Record<string, {
125
+ proceed?: boolean;
126
+ toState?: string;
127
+ }> | null;
118
128
  }
119
129
  /** A complete flow definition with its states and transitions */
120
130
  export interface Flow {
@@ -170,6 +180,7 @@ export interface CreateFlowInput {
170
180
  /** Input for adding a state to a flow */
171
181
  export interface CreateStateInput {
172
182
  name: string;
183
+ agentRole?: string;
173
184
  modelTier?: string;
174
185
  mode?: Mode;
175
186
  promptTemplate?: string;
@@ -198,6 +209,10 @@ export interface CreateGateInput {
198
209
  timeoutMs?: number;
199
210
  failurePrompt?: string;
200
211
  timeoutPrompt?: string;
212
+ outcomes?: Record<string, {
213
+ proceed?: boolean;
214
+ toState?: string;
215
+ }>;
201
216
  }
202
217
  /** Data-access contract for entity lifecycle operations. */
203
218
  export interface IEntityRepository {
@@ -274,7 +289,7 @@ export interface IFlowRepository {
274
289
  /** Data-access contract for invocation lifecycle and claiming. */
275
290
  export interface IInvocationRepository {
276
291
  /** Create a new invocation for an entity at a given stage. */
277
- create(entityId: string, stage: string, prompt: string, mode: Mode, ttlMs?: number, context?: Record<string, unknown>): Promise<Invocation>;
292
+ create(entityId: string, stage: string, prompt: string, mode: Mode, ttlMs: number | undefined, context: Record<string, unknown> | undefined, agentRole: string | null): Promise<Invocation>;
278
293
  /** Get an invocation by ID, or null if not found. */
279
294
  get(id: string): Promise<Invocation | null>;
280
295
  /** Atomically claim an unclaimed invocation for the specified agent. Uses compare-and-swap (UPDATE WHERE claimedBy IS NULL). Returns null if already claimed. */
@@ -329,7 +344,7 @@ export interface IGateRepository {
329
344
  /** Get all gate results for a given entity. */
330
345
  resultsFor(entityId: string): Promise<GateResult[]>;
331
346
  /** Update mutable fields on a gate definition. */
332
- update(id: string, changes: Partial<Pick<Gate, "command" | "functionRef" | "apiConfig" | "timeoutMs" | "failurePrompt" | "timeoutPrompt">>): Promise<Gate>;
347
+ update(id: string, changes: Partial<Pick<Gate, "command" | "functionRef" | "apiConfig" | "timeoutMs" | "failurePrompt" | "timeoutPrompt" | "outcomes">>): Promise<Gate>;
333
348
  /** Delete the gate result for a specific entity+gate combination. */
334
349
  clearResult(entityId: string, gateId: string): Promise<void>;
335
350
  }
@@ -0,0 +1 @@
1
+ ALTER TABLE `gate_definitions` ADD `outcomes` text;
@@ -92,6 +92,13 @@
92
92
  "when": 1773002575474,
93
93
  "tag": "0012_certain_human_fly",
94
94
  "breakpoints": true
95
+ },
96
+ {
97
+ "idx": 13,
98
+ "version": "6",
99
+ "when": 1773100000000,
100
+ "tag": "0013_gate_outcomes",
101
+ "breakpoints": true
95
102
  }
96
103
  ]
97
104
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {