@wopr-network/defcon 1.4.0 → 1.6.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,45 @@
1
+ import { execFile } from "node:child_process";
2
+ import { getHandlebars } from "./handlebars.js";
3
+ export async function executeOnExit(onExit, entity) {
4
+ const hbs = getHandlebars();
5
+ let renderedCommand;
6
+ // Merge artifact refs into entity.refs (same pattern as on-enter.ts)
7
+ const artifactRefs = entity.artifacts !== null &&
8
+ typeof entity.artifacts === "object" &&
9
+ "refs" in entity.artifacts &&
10
+ entity.artifacts.refs !== null &&
11
+ typeof entity.artifacts.refs === "object"
12
+ ? entity.artifacts.refs
13
+ : {};
14
+ const entityForContext = { ...entity, refs: { ...artifactRefs, ...(entity.refs ?? {}) } };
15
+ try {
16
+ renderedCommand = hbs.compile(onExit.command)({ entity: entityForContext });
17
+ }
18
+ catch (err) {
19
+ const error = `onExit template error: ${err instanceof Error ? err.message : String(err)}`;
20
+ return { error, timedOut: false };
21
+ }
22
+ const timeoutMs = onExit.timeout_ms ?? 30000;
23
+ const { exitCode, stdout, stderr, timedOut } = await runCommand(renderedCommand, timeoutMs);
24
+ if (timedOut) {
25
+ return { error: `onExit command timed out after ${timeoutMs}ms`, timedOut: true };
26
+ }
27
+ if (exitCode !== 0) {
28
+ return { error: `onExit command exited with code ${exitCode}: ${stderr || stdout}`, timedOut: false };
29
+ }
30
+ return { error: null, timedOut: false };
31
+ }
32
+ function runCommand(command, timeoutMs) {
33
+ return new Promise((resolve) => {
34
+ execFile("/bin/sh", ["-c", command], { timeout: timeoutMs }, (error, stdout, stderr) => {
35
+ const execErr = error;
36
+ const timedOut = execErr !== null && execErr.killed === true;
37
+ resolve({
38
+ exitCode: execErr ? (typeof execErr.code === "number" ? execErr.code : 1) : 0,
39
+ stdout: stdout.trim(),
40
+ stderr: stderr.trim(),
41
+ timedOut,
42
+ });
43
+ });
44
+ });
45
+ }
@@ -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,11 +9,13 @@ 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,
15
16
  constraints: r.constraints,
16
17
  onEnter: r.onEnter ?? null,
18
+ onExit: r.onExit ?? null,
17
19
  retryAfterMs: r.retryAfterMs ?? null,
18
20
  };
19
21
  }
@@ -167,12 +169,13 @@ export class DrizzleFlowRepository {
167
169
  id,
168
170
  flowId,
169
171
  name: state.name,
170
- agentRole: null,
172
+ agentRole: state.agentRole || null,
171
173
  modelTier: state.modelTier ?? null,
172
174
  mode: state.mode ?? "passive",
173
175
  promptTemplate: state.promptTemplate ?? null,
174
176
  constraints: (state.constraints ?? null),
175
177
  onEnter: (state.onEnter ?? null),
178
+ onExit: (state.onExit ?? null),
176
179
  retryAfterMs: state.retryAfterMs ?? null,
177
180
  };
178
181
  this.db.transaction((tx) => {
@@ -188,6 +191,8 @@ export class DrizzleFlowRepository {
188
191
  const updateValues = {};
189
192
  if (changes.name !== undefined)
190
193
  updateValues.name = changes.name;
194
+ if (changes.agentRole !== undefined)
195
+ updateValues.agentRole = changes.agentRole;
191
196
  if (changes.modelTier !== undefined)
192
197
  updateValues.modelTier = changes.modelTier;
193
198
  if (changes.mode !== undefined)
@@ -198,6 +203,8 @@ export class DrizzleFlowRepository {
198
203
  updateValues.constraints = changes.constraints;
199
204
  if (changes.onEnter !== undefined)
200
205
  updateValues.onEnter = changes.onEnter;
206
+ if (changes.onExit !== undefined)
207
+ updateValues.onExit = changes.onExit;
201
208
  if (changes.retryAfterMs !== undefined)
202
209
  updateValues.retryAfterMs = changes.retryAfterMs;
203
210
  if (Object.keys(updateValues).length > 0) {
@@ -329,11 +336,13 @@ export class DrizzleFlowRepository {
329
336
  id: s.id,
330
337
  flowId,
331
338
  name: s.name,
339
+ agentRole: s.agentRole || null,
332
340
  modelTier: s.modelTier,
333
341
  mode: s.mode ?? "passive",
334
342
  promptTemplate: s.promptTemplate,
335
343
  constraints: s.constraints,
336
344
  onEnter: (s.onEnter ?? null),
345
+ onExit: (s.onExit ?? null),
337
346
  retryAfterMs: s.retryAfterMs ?? null,
338
347
  })
339
348
  .run();
@@ -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,
@@ -498,6 +498,23 @@ export declare const stateDefinitions: import("drizzle-orm/sqlite-core").SQLiteT
498
498
  identity: undefined;
499
499
  generated: undefined;
500
500
  }, {}, {}>;
501
+ onExit: import("drizzle-orm/sqlite-core").SQLiteColumn<{
502
+ name: "on_exit";
503
+ tableName: "state_definitions";
504
+ dataType: "json";
505
+ columnType: "SQLiteTextJson";
506
+ data: unknown;
507
+ driverParam: string;
508
+ notNull: false;
509
+ hasDefault: false;
510
+ isPrimaryKey: false;
511
+ isAutoincrement: false;
512
+ hasRuntimeDefault: false;
513
+ enumValues: undefined;
514
+ baseColumn: never;
515
+ identity: undefined;
516
+ generated: undefined;
517
+ }, {}, {}>;
501
518
  retryAfterMs: import("drizzle-orm/sqlite-core").SQLiteColumn<{
502
519
  name: "retry_after_ms";
503
520
  tableName: "state_definitions";
@@ -689,6 +706,23 @@ export declare const gateDefinitions: import("drizzle-orm/sqlite-core").SQLiteTa
689
706
  }, {}, {
690
707
  length: number | undefined;
691
708
  }>;
709
+ outcomes: import("drizzle-orm/sqlite-core").SQLiteColumn<{
710
+ name: "outcomes";
711
+ tableName: "gate_definitions";
712
+ dataType: "json";
713
+ columnType: "SQLiteTextJson";
714
+ data: unknown;
715
+ driverParam: string;
716
+ notNull: false;
717
+ hasDefault: false;
718
+ isPrimaryKey: false;
719
+ isAutoincrement: false;
720
+ hasRuntimeDefault: false;
721
+ enumValues: undefined;
722
+ baseColumn: never;
723
+ identity: undefined;
724
+ generated: undefined;
725
+ }, {}, {}>;
692
726
  };
693
727
  dialect: "sqlite";
694
728
  }>;
@@ -32,6 +32,7 @@ export const stateDefinitions = sqliteTable("state_definitions", {
32
32
  promptTemplate: text("prompt_template"),
33
33
  constraints: text("constraints", { mode: "json" }),
34
34
  onEnter: text("on_enter", { mode: "json" }),
35
+ onExit: text("on_exit", { mode: "json" }),
35
36
  retryAfterMs: integer("retry_after_ms"),
36
37
  }, (table) => ({
37
38
  flowNameUnique: uniqueIndex("state_definitions_flow_name_unique").on(table.flowId, table.name),
@@ -46,6 +47,7 @@ export const gateDefinitions = sqliteTable("gate_definitions", {
46
47
  timeoutMs: integer("timeout_ms"),
47
48
  failurePrompt: text("failure_prompt"),
48
49
  timeoutPrompt: text("timeout_prompt"),
50
+ outcomes: text("outcomes", { mode: "json" }),
49
51
  });
50
52
  export const transitionRules = sqliteTable("transition_rules", {
51
53
  id: text("id").primaryKey(),
@@ -12,6 +12,11 @@ export interface OnEnterConfig {
12
12
  artifacts: string[];
13
13
  timeout_ms?: number;
14
14
  }
15
+ /** Configuration for running a cleanup command when an entity exits a state */
16
+ export interface OnExitConfig {
17
+ command: string;
18
+ timeout_ms?: number;
19
+ }
15
20
  /** Invocation execution mode */
16
21
  export type Mode = "active" | "passive";
17
22
  /** Runtime entity tracked through a flow */
@@ -36,6 +41,7 @@ export interface Invocation {
36
41
  id: string;
37
42
  entityId: string;
38
43
  stage: string;
44
+ agentRole: string | null;
39
45
  mode: Mode;
40
46
  prompt: string;
41
47
  context: Record<string, unknown> | null;
@@ -82,11 +88,14 @@ export interface State {
82
88
  id: string;
83
89
  flowId: string;
84
90
  name: string;
91
+ /** Agent type identifier — maps to an agent MD file (e.g. "wopr-architect" → ~/.claude/agents/wopr-architect.md). */
92
+ agentRole: string | null;
85
93
  modelTier: string | null;
86
94
  mode: Mode;
87
95
  promptTemplate: string | null;
88
96
  constraints: Record<string, unknown> | null;
89
97
  onEnter: OnEnterConfig | null;
98
+ onExit: OnExitConfig | null;
90
99
  /** Override check_back delay for workers claiming this state. Falls back to Flow.claimRetryAfterMs. */
91
100
  retryAfterMs: number | null;
92
101
  }
@@ -115,6 +124,13 @@ export interface Gate {
115
124
  timeoutMs: number | null;
116
125
  failurePrompt: string | null;
117
126
  timeoutPrompt: string | null;
127
+ /** Named outcome map from structured gate output. Keys are outcome names; values declare
128
+ * where the entity goes. `proceed: true` means the original transition continues.
129
+ * `toState` redirects to a different state. */
130
+ outcomes: Record<string, {
131
+ proceed?: boolean;
132
+ toState?: string;
133
+ }> | null;
118
134
  }
119
135
  /** A complete flow definition with its states and transitions */
120
136
  export interface Flow {
@@ -170,11 +186,13 @@ export interface CreateFlowInput {
170
186
  /** Input for adding a state to a flow */
171
187
  export interface CreateStateInput {
172
188
  name: string;
189
+ agentRole?: string;
173
190
  modelTier?: string;
174
191
  mode?: Mode;
175
192
  promptTemplate?: string;
176
193
  constraints?: Record<string, unknown>;
177
194
  onEnter?: OnEnterConfig;
195
+ onExit?: OnExitConfig;
178
196
  retryAfterMs?: number;
179
197
  }
180
198
  /** Input for adding a transition rule */
@@ -198,6 +216,10 @@ export interface CreateGateInput {
198
216
  timeoutMs?: number;
199
217
  failurePrompt?: string;
200
218
  timeoutPrompt?: string;
219
+ outcomes?: Record<string, {
220
+ proceed?: boolean;
221
+ toState?: string;
222
+ }>;
201
223
  }
202
224
  /** Data-access contract for entity lifecycle operations. */
203
225
  export interface IEntityRepository {
@@ -274,7 +296,7 @@ export interface IFlowRepository {
274
296
  /** Data-access contract for invocation lifecycle and claiming. */
275
297
  export interface IInvocationRepository {
276
298
  /** 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>;
299
+ create(entityId: string, stage: string, prompt: string, mode: Mode, ttlMs: number | undefined, context: Record<string, unknown> | undefined, agentRole: string | null): Promise<Invocation>;
278
300
  /** Get an invocation by ID, or null if not found. */
279
301
  get(id: string): Promise<Invocation | null>;
280
302
  /** 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 +351,7 @@ export interface IGateRepository {
329
351
  /** Get all gate results for a given entity. */
330
352
  resultsFor(entityId: string): Promise<GateResult[]>;
331
353
  /** Update mutable fields on a gate definition. */
332
- update(id: string, changes: Partial<Pick<Gate, "command" | "functionRef" | "apiConfig" | "timeoutMs" | "failurePrompt" | "timeoutPrompt">>): Promise<Gate>;
354
+ update(id: string, changes: Partial<Pick<Gate, "command" | "functionRef" | "apiConfig" | "timeoutMs" | "failurePrompt" | "timeoutPrompt" | "outcomes">>): Promise<Gate>;
333
355
  /** Delete the gate result for a specific entity+gate combination. */
334
356
  clearResult(entityId: string, gateId: string): Promise<void>;
335
357
  }
@@ -0,0 +1 @@
1
+ ALTER TABLE `gate_definitions` ADD `outcomes` text;
@@ -0,0 +1 @@
1
+ ALTER TABLE `state_definitions` ADD `on_exit` text;