@directive-run/claude-plugin 1.16.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.
Files changed (83) hide show
  1. package/.claude-plugin/plugin.json +6 -0
  2. package/LICENSE +26 -0
  3. package/README.md +113 -0
  4. package/dist/index.cjs +2 -0
  5. package/dist/index.cjs.map +1 -0
  6. package/dist/index.d.cts +66 -0
  7. package/dist/index.d.ts +66 -0
  8. package/dist/index.js +2 -0
  9. package/dist/index.js.map +1 -0
  10. package/package.json +64 -0
  11. package/skills/README.md +9 -0
  12. package/skills/building-ai-agents/SKILL.md +414 -0
  13. package/skills/building-ai-agents/ai-adapters.md +257 -0
  14. package/skills/building-ai-agents/ai-agents-streaming.md +307 -0
  15. package/skills/building-ai-agents/ai-communication.md +304 -0
  16. package/skills/building-ai-agents/api-skeleton.md +5 -0
  17. package/skills/building-ai-agents/examples.md +849 -0
  18. package/skills/building-ai-orchestrators/SKILL.md +401 -0
  19. package/skills/building-ai-orchestrators/ai-multi-agent.md +375 -0
  20. package/skills/building-ai-orchestrators/ai-orchestrator.md +466 -0
  21. package/skills/building-ai-orchestrators/ai-tasks.md +268 -0
  22. package/skills/building-ai-orchestrators/api-skeleton.md +5 -0
  23. package/skills/building-ai-orchestrators/examples.md +1609 -0
  24. package/skills/building-directive-systems/SKILL.md +499 -0
  25. package/skills/building-directive-systems/api-skeleton.md +5 -0
  26. package/skills/building-directive-systems/examples.md +4149 -0
  27. package/skills/building-directive-systems/multi-module.md +327 -0
  28. package/skills/building-directive-systems/plugins.md +361 -0
  29. package/skills/building-directive-systems/react-adapter.md +376 -0
  30. package/skills/building-directive-systems/system-api.md +463 -0
  31. package/skills/getting-started-with-directive/SKILL.md +198 -0
  32. package/skills/getting-started-with-directive/api-skeleton.md +5 -0
  33. package/skills/getting-started-with-directive/core-patterns.md +240 -0
  34. package/skills/getting-started-with-directive/examples.md +87 -0
  35. package/skills/getting-started-with-directive/sitemap.md +202 -0
  36. package/skills/hardening-ai-systems/SKILL.md +377 -0
  37. package/skills/hardening-ai-systems/ai-budget-resilience.md +408 -0
  38. package/skills/hardening-ai-systems/ai-guardrails-memory.md +370 -0
  39. package/skills/hardening-ai-systems/ai-security.md +400 -0
  40. package/skills/hardening-ai-systems/api-skeleton.md +5 -0
  41. package/skills/hardening-ai-systems/examples.md +628 -0
  42. package/skills/migrating-to-directive/SKILL.md +249 -0
  43. package/skills/migrating-to-directive/anti-patterns.md +382 -0
  44. package/skills/migrating-to-directive/api-skeleton.md +5 -0
  45. package/skills/migrating-to-directive/core-patterns.md +240 -0
  46. package/skills/migrating-to-directive/examples.md +511 -0
  47. package/skills/migrating-to-directive/schema-types.md +273 -0
  48. package/skills/reviewing-directive-code/SKILL.md +232 -0
  49. package/skills/reviewing-directive-code/anti-patterns.md +382 -0
  50. package/skills/reviewing-directive-code/api-skeleton.md +5 -0
  51. package/skills/reviewing-directive-code/core-patterns.md +240 -0
  52. package/skills/reviewing-directive-code/examples.md +463 -0
  53. package/skills/reviewing-directive-code/naming.md +321 -0
  54. package/skills/scaffolding-directive-modules/SKILL.md +218 -0
  55. package/skills/scaffolding-directive-modules/api-skeleton.md +5 -0
  56. package/skills/scaffolding-directive-modules/core-patterns.md +240 -0
  57. package/skills/scaffolding-directive-modules/examples.md +1386 -0
  58. package/skills/scaffolding-directive-modules/naming.md +321 -0
  59. package/skills/scaffolding-directive-modules/schema-types.md +273 -0
  60. package/skills/testing-ai-systems/SKILL.md +398 -0
  61. package/skills/testing-ai-systems/ai-debug-observability.md +329 -0
  62. package/skills/testing-ai-systems/ai-mcp-rag.md +293 -0
  63. package/skills/testing-ai-systems/ai-testing-evals.md +485 -0
  64. package/skills/testing-ai-systems/api-skeleton.md +5 -0
  65. package/skills/testing-ai-systems/examples.md +1261 -0
  66. package/skills/testing-directive-code/SKILL.md +479 -0
  67. package/skills/testing-directive-code/api-skeleton.md +5 -0
  68. package/skills/testing-directive-code/examples.md +156 -0
  69. package/skills/testing-directive-code/history.md +351 -0
  70. package/skills/testing-directive-code/testing.md +412 -0
  71. package/skills/writing-directive-constraints/SKILL.md +486 -0
  72. package/skills/writing-directive-constraints/api-skeleton.md +5 -0
  73. package/skills/writing-directive-constraints/constraints.md +274 -0
  74. package/skills/writing-directive-constraints/error-boundaries.md +330 -0
  75. package/skills/writing-directive-constraints/examples.md +1361 -0
  76. package/skills/writing-directive-constraints/resolvers.md +367 -0
  77. package/skills/writing-directive-modules/SKILL.md +342 -0
  78. package/skills/writing-directive-modules/anti-patterns.md +382 -0
  79. package/skills/writing-directive-modules/api-skeleton.md +5 -0
  80. package/skills/writing-directive-modules/core-patterns.md +240 -0
  81. package/skills/writing-directive-modules/examples.md +1639 -0
  82. package/skills/writing-directive-modules/naming.md +321 -0
  83. package/skills/writing-directive-modules/schema-types.md +273 -0
@@ -0,0 +1,1261 @@
1
+ # Examples
2
+
3
+ > Auto-generated from extracted examples. Do not edit manually.
4
+
5
+ ## ai-orchestrator
6
+
7
+ ```typescript
8
+ // Example: ai-orchestrator
9
+ // Source: examples/checkers/src/ai-orchestrator.ts
10
+ // Extracted for AI rules — DOM wiring stripped
11
+
12
+ /**
13
+ * Checkers AI Orchestrator
14
+ *
15
+ * Composes directive AI adapter features via explicit wiring:
16
+ *
17
+ * 1. Agent Orchestrator — Manages Claude API via generic AgentRunner + guardrails
18
+ * 2. Memory — Sliding window (30 messages) with auto-summarization
19
+ * 3. Output Guardrail — Validates move JSON schema before accepting
20
+ * 4. Rate Limiter — 10 requests/min to prevent runaway API calls
21
+ * 5. Circuit Breaker — After 3 failures, falls back to local minimax for 30s
22
+ * 6. Cost Tracking — Token count + estimated cost at Haiku rates
23
+ * 7. Streaming — Token-by-token chat delivery with length guardrail
24
+ * 8. Multi-Agent — Parallel move + analysis agents
25
+ * 9. Communication Bus — Agent-to-agent INFORM messages for move/chat events
26
+ * 10. Semantic Cache — Hash-based position caching (0.98 threshold)
27
+ * 11. Observability — Metrics, tracing, alerting dashboard
28
+ * 12. OTLP Exporter — Periodic export to OpenTelemetry collector
29
+ */
30
+
31
+ import {
32
+ type AgentLike,
33
+ CircuitBreakerOpenError,
34
+ type InputGuardrailData,
35
+ type NamedGuardrail,
36
+ type RunResult,
37
+ createAgentMemory,
38
+ createAgentOrchestrator,
39
+ createLengthStreamingGuardrail,
40
+ createMessageBus,
41
+ createMultiAgentOrchestrator,
42
+ createOutputSchemaGuardrail,
43
+ createSemanticCache,
44
+ createSlidingWindowStrategy,
45
+ createStreamingRunner,
46
+ createTestEmbedder,
47
+ estimateCost,
48
+ parallel,
49
+ } from "@directive-run/ai";
50
+ import type { CacheStats } from "@directive-run/ai";
51
+ import {
52
+ type CircuitState,
53
+ createAgentMetrics,
54
+ createCircuitBreaker,
55
+ createOTLPExporter,
56
+ createObservability,
57
+ } from "@directive-run/core/plugins";
58
+ import {
59
+ analysisAgent,
60
+ chatAgent,
61
+ formatLegalMoves,
62
+ moveAgent,
63
+ renderBoardForClaude,
64
+ runClaude,
65
+ runClaudeWithCallbacks,
66
+ } from "./claude-adapter.js";
67
+ import type { Board, Move, Player } from "./rules.js";
68
+ import { pickAiMove } from "./rules.js";
69
+
70
+ // ============================================================================
71
+ // Types
72
+ // ============================================================================
73
+
74
+ export interface MoveResult {
75
+ from: number;
76
+ to: number;
77
+ reasoning: string;
78
+ chat: string;
79
+ analysis: string | null;
80
+ isLocalFallback: boolean;
81
+ isCached: boolean;
82
+ }
83
+
84
+ export interface MoveWithAnalysis {
85
+ move: { from: number; to: number; reasoning: string; chat: string };
86
+ analysis: string | null;
87
+ }
88
+
89
+ export interface CheckersAI {
90
+ requestMove(
91
+ board: Board,
92
+ player: Player,
93
+ legalMoves: Move[],
94
+ humanMoveDesc?: string,
95
+ ): Promise<MoveResult>;
96
+ sendChat(
97
+ message: string,
98
+ onToken?: (token: string) => void,
99
+ ): Promise<string | null>;
100
+ reset(): void;
101
+ getState(): {
102
+ isThinking: boolean;
103
+ totalTokens: number;
104
+ estimatedCost: number;
105
+ circuitState: CircuitState;
106
+ memoryMessageCount: number;
107
+ cacheStats: CacheStats;
108
+ busMessageCount: number;
109
+ };
110
+ destroy(): void;
111
+ /** Escape hatch for dashboard rendering */
112
+ readonly observability: ReturnType<typeof createObservability> | null;
113
+ }
114
+
115
+ // ============================================================================
116
+ // Move Schema Validation
117
+ // ============================================================================
118
+
119
+ function validateMoveOutput(value: unknown): {
120
+ valid: boolean;
121
+ errors?: string[];
122
+ } {
123
+ if (typeof value !== "object" || value === null) {
124
+ return { valid: false, errors: ["Expected an object"] };
125
+ }
126
+ const obj = value as Record<string, unknown>;
127
+ const errors: string[] = [];
128
+ if (typeof obj.from !== "number") errors.push("'from' must be a number");
129
+ if (typeof obj.to !== "number") errors.push("'to' must be a number");
130
+ if (typeof obj.reasoning !== "string")
131
+ errors.push("'reasoning' must be a string");
132
+ if (typeof obj.chat !== "string") errors.push("'chat' must be a string");
133
+
134
+ return errors.length > 0 ? { valid: false, errors } : { valid: true };
135
+ }
136
+
137
+ // ============================================================================
138
+ // Merge function for parallel move + analysis
139
+ // ============================================================================
140
+
141
+ function mergeResults(results: RunResult<unknown>[]): MoveWithAnalysis {
142
+ const moveResult = results[0]?.output as
143
+ | { from: number; to: number; reasoning: string; chat: string }
144
+ | undefined;
145
+ const analysisResult = results[1]?.output as string | undefined;
146
+
147
+ return {
148
+ move: moveResult ?? {
149
+ from: -1,
150
+ to: -1,
151
+ reasoning: "No result",
152
+ chat: "Something went wrong",
153
+ },
154
+ analysis: analysisResult ?? null,
155
+ };
156
+ }
157
+
158
+ // ============================================================================
159
+ // Factory
160
+ // ============================================================================
161
+
162
+ export function createCheckersAI(): CheckersAI {
163
+ let isThinking = false;
164
+ let totalTokens = 0;
165
+ const costRatePerMillion = 2.4;
166
+
167
+ // --- Features ---
168
+
169
+ const memory = createAgentMemory({
170
+ strategy: createSlidingWindowStrategy(),
171
+ strategyConfig: { maxMessages: 30, preserveRecentCount: 6 },
172
+ autoManage: true,
173
+ });
174
+
175
+ const circuitBreaker = createCircuitBreaker({
176
+ failureThreshold: 3,
177
+ recoveryTimeMs: 30000,
178
+ name: "checkers-ai",
179
+ });
180
+
181
+ const cache = createSemanticCache({
182
+ embedder: createTestEmbedder(),
183
+ similarityThreshold: 0.98,
184
+ maxCacheSize: 200,
185
+ ttlMs: 600_000,
186
+ });
187
+
188
+ const obs = createObservability({
189
+ serviceName: "checkers-ai",
190
+ metrics: { enabled: true },
191
+ tracing: { enabled: true, sampleRate: 1.0 },
192
+ alerts: [
193
+ { metric: "agent.errors", threshold: 5, operator: ">", action: "warn" },
194
+ {
195
+ metric: "agent.latency",
196
+ threshold: 10000,
197
+ operator: ">",
198
+ action: "warn",
199
+ },
200
+ ],
201
+ });
202
+
203
+ const metrics = createAgentMetrics(obs);
204
+
205
+ const otlpExporter = createOTLPExporter({
206
+ endpoint: "http://localhost:4318",
207
+ serviceName: "checkers-ai",
208
+ onError: (err) => {
209
+ console.debug(
210
+ `[OTLP] export failed (collector not running?):`,
211
+ err.message,
212
+ );
213
+ },
214
+ });
215
+
216
+ const otlpInterval = setInterval(() => {
217
+ try {
218
+ const data = obs.export();
219
+ if (data.metrics.length > 0) otlpExporter.exportMetrics(data.metrics);
220
+ if (data.traces.length > 0) otlpExporter.exportTraces(data.traces);
221
+ } catch (err) {
222
+ console.debug("[OTLP] periodic export error:", err);
223
+ }
224
+ }, 15_000);
225
+
226
+ const bus = createMessageBus({ maxHistory: 100 });
227
+
228
+ // --- Rate limiter as input guardrail ---
229
+ const rateLimitTimestamps: number[] = [];
230
+ let rateLimitStartIdx = 0;
231
+ const MAX_PER_MINUTE = 10;
232
+
233
+ const rateLimitGuardrail: NamedGuardrail<InputGuardrailData> = {
234
+ name: "rate-limit",
235
+ fn: () => {
236
+ const now = Date.now();
237
+ const windowStart = now - 60_000;
238
+ while (
239
+ rateLimitStartIdx < rateLimitTimestamps.length &&
240
+ rateLimitTimestamps[rateLimitStartIdx]! < windowStart
241
+ ) {
242
+ rateLimitStartIdx++;
243
+ }
244
+ if (
245
+ rateLimitStartIdx > rateLimitTimestamps.length / 2 &&
246
+ rateLimitStartIdx > 100
247
+ ) {
248
+ rateLimitTimestamps.splice(0, rateLimitStartIdx);
249
+ rateLimitStartIdx = 0;
250
+ }
251
+ const active = rateLimitTimestamps.length - rateLimitStartIdx;
252
+ if (active >= MAX_PER_MINUTE) {
253
+ return {
254
+ passed: false,
255
+ reason: `Rate limit exceeded (${MAX_PER_MINUTE}/min)`,
256
+ };
257
+ }
258
+ rateLimitTimestamps.push(now);
259
+
260
+ return { passed: true };
261
+ },
262
+ };
263
+
264
+ const moveSchemaGuardrail = createOutputSchemaGuardrail({
265
+ validate: validateMoveOutput,
266
+ errorPrefix: "Invalid move response",
267
+ });
268
+
269
+ // --- Core orchestrator ---
270
+ const orchestrator = createAgentOrchestrator({
271
+ runner: runClaude,
272
+ maxTokenBudget: 50000,
273
+ memory,
274
+ circuitBreaker,
275
+ guardrails: {
276
+ input: [rateLimitGuardrail],
277
+ output: [moveSchemaGuardrail],
278
+ },
279
+ });
280
+
281
+ // --- Multi-agent ---
282
+ const agentRegistry = {
283
+ move: {
284
+ agent: moveAgent,
285
+ description: "Selects the best move",
286
+ capabilities: ["move"] as string[],
287
+ },
288
+ chat: {
289
+ agent: chatAgent,
290
+ description: "Free-form chat",
291
+ capabilities: ["chat"] as string[],
292
+ },
293
+ analysis: {
294
+ agent: analysisAgent,
295
+ description: "Strategic analysis",
296
+ capabilities: ["analysis"] as string[],
297
+ },
298
+ };
299
+
300
+ const multi = createMultiAgentOrchestrator({
301
+ runner: runClaude,
302
+ agents: agentRegistry,
303
+ patterns: {
304
+ moveWithAnalysis: parallel<MoveWithAnalysis>(
305
+ ["move", "analysis"],
306
+ mergeResults,
307
+ { minSuccess: 1, timeout: 15000 },
308
+ ),
309
+ },
310
+ });
311
+
312
+ // --- Streaming runner ---
313
+ const streamingRunner = createStreamingRunner(runClaudeWithCallbacks, {
314
+ streamingGuardrails: [createLengthStreamingGuardrail({ maxTokens: 500 })],
315
+ });
316
+
317
+ // --- Helpers ---
318
+
319
+ function resolveAgent(agentId: string): AgentLike {
320
+ const reg = agentRegistry[agentId as keyof typeof agentRegistry];
321
+ if (!reg) {
322
+ throw new Error(`[CheckersAI] Agent "${agentId}" not found`);
323
+ }
324
+
325
+ return reg.agent;
326
+ }
327
+
328
+ function buildMoveInput(
329
+ board: Board,
330
+ player: Player,
331
+ legalMoves: Move[],
332
+ humanMoveDesc?: string,
333
+ ): string {
334
+ const boardStr = renderBoardForClaude(board);
335
+ const movesStr = formatLegalMoves(legalMoves);
336
+ let input = "";
337
+ if (humanMoveDesc) {
338
+ input += `Human's move: ${humanMoveDesc}\n\n`;
339
+ }
340
+ input += `Current board:\n${boardStr}\n\nYour legal moves (you MUST pick one):\n${movesStr}\n\nPick your move.`;
341
+
342
+ return input;
343
+ }
344
+
345
+ function localFallback(
346
+ board: Board,
347
+ player: Player,
348
+ legalMoves: Move[],
349
+ reason: string,
350
+ ): MoveResult {
351
+ const move = pickAiMove(board, player) ?? legalMoves[0];
352
+
353
+ return {
354
+ from: move.from,
355
+ to: move.to,
356
+ reasoning: `Local AI: ${reason}`,
357
+ chat: reason,
358
+ analysis: null,
359
+ isLocalFallback: true,
360
+ isCached: false,
361
+ };
362
+ }
363
+
364
+ // --- Public API ---
365
+
366
+ async function requestMove(
367
+ board: Board,
368
+ player: Player,
369
+ legalMoves: Move[],
370
+ humanMoveDesc?: string,
371
+ ): Promise<MoveResult> {
372
+ if (legalMoves.length === 0) {
373
+ return {
374
+ from: -1,
375
+ to: -1,
376
+ reasoning: "No moves",
377
+ chat: "No moves!",
378
+ analysis: null,
379
+ isLocalFallback: true,
380
+ isCached: false,
381
+ };
382
+ }
383
+
384
+ isThinking = true;
385
+ const input = buildMoveInput(board, player, legalMoves, humanMoveDesc);
386
+
387
+ // Cache check
388
+ try {
389
+ const cached = await cache.lookup(input, "moveWithAnalysis");
390
+ if (cached.hit && cached.entry) {
391
+ obs.incrementCounter("cache.hits");
392
+ try {
393
+ const parsed = JSON.parse(cached.entry.response) as MoveWithAnalysis;
394
+ isThinking = false;
395
+
396
+ return {
397
+ from: parsed.move.from,
398
+ to: parsed.move.to,
399
+ reasoning: parsed.move.reasoning,
400
+ chat: parsed.move.chat,
401
+ analysis: parsed.analysis,
402
+ isLocalFallback: false,
403
+ isCached: true,
404
+ };
405
+ } catch {
406
+ // Invalid cache entry — fall through
407
+ }
408
+ }
409
+ obs.incrementCounter("cache.misses");
410
+ } catch {
411
+ // Cache lookup failed — treat as miss
412
+ }
413
+
414
+ const span = obs.startSpan("pattern.moveWithAnalysis");
415
+ const startTime = Date.now();
416
+
417
+ try {
418
+ const result = await multi.runPattern<MoveWithAnalysis>(
419
+ "moveWithAnalysis",
420
+ input,
421
+ );
422
+ const latencyMs = Date.now() - startTime;
423
+ const parsed = result.move;
424
+
425
+ // Track metrics
426
+ obs.endSpan(span.spanId, "ok");
427
+ metrics.trackRun("moveWithAnalysis", { success: true, latencyMs });
428
+
429
+ // Cache store
430
+ try {
431
+ await cache.store(input, JSON.stringify(result), "moveWithAnalysis");
432
+ } catch {
433
+ // Non-fatal
434
+ }
435
+
436
+ // Bus publish
437
+ bus.publish({
438
+ type: "INFORM",
439
+ from: "moveWithAnalysis",
440
+ to: "*",
441
+ topic: "moveWithAnalysis.completed",
442
+ content: {},
443
+ } as Parameters<typeof bus.publish>[0]);
444
+
445
+ // Validate the move is legal
446
+ const isLegal = legalMoves.some(
447
+ (m) => m.from === parsed.from && m.to === parsed.to,
448
+ );
449
+ if (!isLegal) {
450
+ console.warn(
451
+ "[CheckersAI] Illegal move returned, falling back",
452
+ parsed,
453
+ );
454
+ isThinking = false;
455
+
456
+ return localFallback(
457
+ board,
458
+ player,
459
+ legalMoves,
460
+ "Hmm, I tried an illegal move. Let me pick again!",
461
+ );
462
+ }
463
+
464
+ isThinking = false;
465
+
466
+ return {
467
+ from: parsed.from,
468
+ to: parsed.to,
469
+ reasoning: parsed.reasoning,
470
+ chat: parsed.chat,
471
+ analysis: result.analysis,
472
+ isLocalFallback: false,
473
+ isCached: false,
474
+ };
475
+ } catch (err) {
476
+ isThinking = false;
477
+ const latencyMs = Date.now() - startTime;
478
+ obs.endSpan(span.spanId, "error");
479
+ metrics.trackRun("moveWithAnalysis", { success: false, latencyMs });
480
+
481
+ if (err instanceof CircuitBreakerOpenError) {
482
+ return localFallback(
483
+ board,
484
+ player,
485
+ legalMoves,
486
+ "Circuit breaker open — using local AI while I recover.",
487
+ );
488
+ }
489
+
490
+ console.error("[CheckersAI] Move error:", err);
491
+ const msg = err instanceof Error ? err.message : "Unknown error";
492
+
493
+ return localFallback(
494
+ board,
495
+ player,
496
+ legalMoves,
497
+ `Error: ${msg}. Using local AI.`,
498
+ );
499
+ }
500
+ }
501
+
502
+ async function sendChat(
503
+ message: string,
504
+ onToken?: (token: string) => void,
505
+ ): Promise<string | null> {
506
+ try {
507
+ const agent = resolveAgent("chat");
508
+ let reply: string;
509
+
510
+ if (onToken && streamingRunner) {
511
+ // Streaming: token-by-token delivery
512
+ const { stream, result } = streamingRunner<string>(agent, message);
513
+ for await (const chunk of stream) {
514
+ if (chunk.type === "token" && chunk.data) onToken(chunk.data);
515
+ }
516
+ const finalResult = await result;
517
+ totalTokens += finalResult.totalTokens;
518
+ reply =
519
+ typeof finalResult.output === "string"
520
+ ? finalResult.output
521
+ : String(finalResult.output);
522
+ } else {
523
+ // Non-streaming: skip output guardrails for chat
524
+ const result = await orchestrator.run<string>(agent, message, {
525
+ outputGuardrails: [],
526
+ });
527
+ totalTokens += result.totalTokens;
528
+ reply = result.output;
529
+ }
530
+
531
+ return reply;
532
+ } catch (err) {
533
+ if (err instanceof CircuitBreakerOpenError) {
534
+ return "I'm having trouble connecting right now. Try again in a bit!";
535
+ }
536
+ console.error("[CheckersAI] Chat error:", err);
537
+
538
+ return null;
539
+ }
540
+ }
541
+
542
+ function reset(): void {
543
+ memory.clear();
544
+ circuitBreaker.reset();
545
+ cache.clear();
546
+ obs.clear();
547
+ bus.clear();
548
+ multi.reset();
549
+ orchestrator.reset();
550
+ rateLimitTimestamps.length = 0;
551
+ rateLimitStartIdx = 0;
552
+ totalTokens = 0;
553
+ isThinking = false;
554
+ }
555
+
556
+ function getState() {
557
+ return {
558
+ isThinking,
559
+ totalTokens,
560
+ estimatedCost:
561
+ costRatePerMillion > 0
562
+ ? estimateCost(totalTokens, costRatePerMillion)
563
+ : 0,
564
+ circuitState: circuitBreaker.getState(),
565
+ memoryMessageCount: memory.getState()?.messages?.length ?? 0,
566
+ cacheStats: cache.getStats(),
567
+ busMessageCount: bus.getHistory()?.length ?? 0,
568
+ };
569
+ }
570
+
571
+ function destroy(): void {
572
+ clearInterval(otlpInterval);
573
+ // Flush OTLP one final time
574
+ try {
575
+ const data = obs.export();
576
+ if (data.metrics.length > 0) otlpExporter.exportMetrics(data.metrics);
577
+ if (data.traces.length > 0) otlpExporter.exportTraces(data.traces);
578
+ } catch {
579
+ // Best-effort flush on dispose
580
+ }
581
+ orchestrator.destroy();
582
+ multi.destroy();
583
+ obs.destroy();
584
+ }
585
+
586
+ return {
587
+ requestMove,
588
+ sendChat,
589
+ reset,
590
+ getState,
591
+ dispose,
592
+ get observability() {
593
+ return obs;
594
+ },
595
+ };
596
+ }
597
+ ```
598
+
599
+ ## fraud-analysis
600
+
601
+ ```typescript
602
+ // Example: fraud-analysis
603
+ // Source: examples/fraud-analysis/src/fraud-analysis.ts
604
+ // Pure module file — no DOM wiring
605
+
606
+ /**
607
+ * Fraud Case Analysis — Directive Module
608
+ *
609
+ * Multi-stage fraud detection pipeline showcasing every major Directive feature:
610
+ * - 6 constraints with priority + `after` ordering (including competing constraints)
611
+ * - 6 resolvers with retry policies and custom dedup keys
612
+ * - 3 effects with explicit deps
613
+ * - 9 derivations with composition
614
+ * - Local PII detection + checkpoint store
615
+ * - DevTools panel with time-travel debugging
616
+ */
617
+
618
+ import {
619
+ type ModuleSchema,
620
+ createModule,
621
+ createSystem,
622
+ t,
623
+ } from "@directive-run/core";
624
+ import { devtoolsPlugin } from "@directive-run/core/plugins";
625
+ import { InMemoryCheckpointStore } from "./checkpoint.js";
626
+ import { detectPII, redactPII } from "./pii.js";
627
+
628
+ import {
629
+ type CheckpointEntry,
630
+ type Disposition,
631
+ type FlagEvent,
632
+ type FraudCase,
633
+ type PipelineStage,
634
+ type Severity,
635
+ type TimelineEntry,
636
+ getMockEnrichment,
637
+ } from "./mock-data.js";
638
+
639
+ // ============================================================================
640
+ // Timeline (external mutable array, same pattern as ai-checkpoint)
641
+ // ============================================================================
642
+
643
+ export const timeline: TimelineEntry[] = [];
644
+
645
+ export function addTimeline(
646
+ type: TimelineEntry["type"],
647
+ message: string,
648
+ ): void {
649
+ timeline.push({
650
+ time: new Date().toLocaleTimeString(),
651
+ type,
652
+ message,
653
+ });
654
+ }
655
+
656
+ // ============================================================================
657
+ // Checkpoint Store
658
+ // ============================================================================
659
+
660
+ export const checkpointStore = new InMemoryCheckpointStore();
661
+
662
+ // ============================================================================
663
+ // Analysis Helpers
664
+ // ============================================================================
665
+
666
+ interface AnalysisResult {
667
+ riskScore: number;
668
+ severity: Severity;
669
+ disposition: Disposition;
670
+ analysisNotes: string;
671
+ }
672
+
673
+ /** Deterministic risk scoring formula */
674
+ function analyzeWithFormula(fraudCase: FraudCase): AnalysisResult {
675
+ const avgSignalRisk =
676
+ fraudCase.signals.length > 0
677
+ ? fraudCase.signals.reduce((sum, s) => sum + s.risk, 0) /
678
+ fraudCase.signals.length
679
+ : 50;
680
+
681
+ const totalAmount = fraudCase.events.reduce((sum, e) => sum + e.amount, 0);
682
+ const amountFactor = Math.min(totalAmount / 10000, 1) * 30;
683
+ const eventFactor = Math.min(fraudCase.events.length / 10, 1) * 20;
684
+ const piiFactor = fraudCase.events.some((e) => e.piiFound) ? 15 : 0;
685
+
686
+ const riskScore = Math.min(
687
+ 100,
688
+ Math.round(avgSignalRisk * 0.5 + amountFactor + eventFactor + piiFactor),
689
+ );
690
+
691
+ let severity: Severity = "low";
692
+ if (riskScore >= 80) {
693
+ severity = "critical";
694
+ } else if (riskScore >= 60) {
695
+ severity = "high";
696
+ } else if (riskScore >= 40) {
697
+ severity = "medium";
698
+ }
699
+
700
+ let disposition: Disposition = "pending";
701
+ let notes = `Risk: ${riskScore}/100. Signals: ${fraudCase.signals.map((s) => s.source).join(", ")}.`;
702
+
703
+ if (riskScore <= 30) {
704
+ disposition = "cleared";
705
+ notes += " Auto-cleared: low risk.";
706
+ } else if (riskScore <= 50) {
707
+ disposition = "flagged";
708
+ notes += " Flagged for monitoring.";
709
+ }
710
+
711
+ return { riskScore, severity, disposition, analysisNotes: notes };
712
+ }
713
+
714
+ // ============================================================================
715
+ // Schema
716
+ // ============================================================================
717
+
718
+ export const fraudSchema = {
719
+ facts: {
720
+ stage: t.string<PipelineStage>(),
721
+ flagEvents: t.array<FlagEvent>(),
722
+ cases: t.array<FraudCase>(),
723
+ isRunning: t.boolean(),
724
+ totalEventsProcessed: t.number(),
725
+ totalPiiDetections: t.number(),
726
+ analysisBudget: t.number(),
727
+ maxAnalysisBudget: t.number(),
728
+ riskThreshold: t.number(),
729
+ lastError: t.string(),
730
+ checkpoints: t.array<CheckpointEntry>(),
731
+ selectedScenario: t.string(),
732
+ },
733
+ derivations: {
734
+ ungroupedCount: t.number(),
735
+ caseCount: t.number(),
736
+ criticalCaseCount: t.number(),
737
+ pendingAnalysisCount: t.number(),
738
+ needsHumanReview: t.boolean(),
739
+ budgetExhausted: t.boolean(),
740
+ completionPercentage: t.number(),
741
+ averageRiskScore: t.number(),
742
+ dispositionSummary: t.object<Record<string, number>>(),
743
+ },
744
+ events: {
745
+ ingestEvents: { events: t.array<FlagEvent>() },
746
+ setRiskThreshold: { value: t.number() },
747
+ setBudget: { value: t.number() },
748
+ selectScenario: { key: t.string() },
749
+ reset: {},
750
+ },
751
+ requirements: {
752
+ NORMALIZE_EVENTS: {},
753
+ GROUP_EVENTS: {},
754
+ ENRICH_CASE: { caseId: t.string() },
755
+ ANALYZE_CASE: { caseId: t.string() },
756
+ HUMAN_REVIEW: { caseId: t.string() },
757
+ ESCALATE: { caseId: t.string() },
758
+ },
759
+ } satisfies ModuleSchema;
760
+
761
+ // ============================================================================
762
+ // Module
763
+ // ============================================================================
764
+
765
+ export const fraudAnalysisModule = createModule("fraud", {
766
+ schema: fraudSchema,
767
+
768
+ init: (facts) => {
769
+ facts.stage = "idle";
770
+ facts.flagEvents = [];
771
+ facts.cases = [];
772
+ facts.isRunning = false;
773
+ facts.totalEventsProcessed = 0;
774
+ facts.totalPiiDetections = 0;
775
+ facts.analysisBudget = 300;
776
+ facts.maxAnalysisBudget = 300;
777
+ facts.riskThreshold = 70;
778
+ facts.lastError = "";
779
+ facts.checkpoints = [];
780
+ facts.selectedScenario = "card-skimming";
781
+ },
782
+
783
+ // ============================================================================
784
+ // Derivations (9)
785
+ // ============================================================================
786
+
787
+ derive: {
788
+ ungroupedCount: (facts) => {
789
+ return facts.flagEvents.filter((e) => !e.grouped).length;
790
+ },
791
+
792
+ caseCount: (facts) => {
793
+ return facts.cases.length;
794
+ },
795
+
796
+ criticalCaseCount: (facts) => {
797
+ return facts.cases.filter((c) => c.severity === "critical").length;
798
+ },
799
+
800
+ pendingAnalysisCount: (facts) => {
801
+ return facts.cases.filter((c) => c.enriched && !c.analyzed).length;
802
+ },
803
+
804
+ needsHumanReview: (facts) => {
805
+ return facts.cases.some(
806
+ (c) => c.riskScore > facts.riskThreshold && c.disposition === "pending",
807
+ );
808
+ },
809
+
810
+ budgetExhausted: (facts) => {
811
+ return facts.analysisBudget <= 0;
812
+ },
813
+
814
+ completionPercentage: (facts) => {
815
+ const stages: PipelineStage[] = [
816
+ "idle",
817
+ "ingesting",
818
+ "normalizing",
819
+ "grouping",
820
+ "enriching",
821
+ "analyzing",
822
+ "complete",
823
+ ];
824
+ const idx = stages.indexOf(facts.stage);
825
+ if (idx < 0) {
826
+ return 0;
827
+ }
828
+
829
+ return Math.round((idx / (stages.length - 1)) * 100);
830
+ },
831
+
832
+ averageRiskScore: (facts) => {
833
+ if (facts.cases.length === 0) {
834
+ return 0;
835
+ }
836
+
837
+ const sum = facts.cases.reduce((acc, c) => acc + c.riskScore, 0);
838
+
839
+ return Math.round(sum / facts.cases.length);
840
+ },
841
+
842
+ // Composition: derives from cases (same source as caseCount)
843
+ dispositionSummary: (facts) => {
844
+ const summary: Record<string, number> = {};
845
+ for (const c of facts.cases) {
846
+ summary[c.disposition] = (summary[c.disposition] || 0) + 1;
847
+ }
848
+
849
+ return summary;
850
+ },
851
+ },
852
+
853
+ // ============================================================================
854
+ // Events
855
+ // ============================================================================
856
+
857
+ events: {
858
+ ingestEvents: (facts, { events }) => {
859
+ facts.flagEvents = [...facts.flagEvents, ...events];
860
+ facts.totalEventsProcessed = facts.totalEventsProcessed + events.length;
861
+ facts.stage = "ingesting";
862
+ facts.isRunning = true;
863
+ facts.lastError = "";
864
+ },
865
+
866
+ setRiskThreshold: (facts, { value }) => {
867
+ facts.riskThreshold = Math.max(50, Math.min(90, value));
868
+ },
869
+
870
+ setBudget: (facts, { value }) => {
871
+ facts.analysisBudget = Math.max(0, Math.min(500, value));
872
+ facts.maxAnalysisBudget = Math.max(facts.maxAnalysisBudget, value);
873
+ },
874
+
875
+ selectScenario: (facts, { key }) => {
876
+ facts.selectedScenario = key;
877
+ },
878
+
879
+ reset: (facts) => {
880
+ facts.stage = "idle";
881
+ facts.flagEvents = [];
882
+ facts.cases = [];
883
+ facts.isRunning = false;
884
+ facts.totalEventsProcessed = 0;
885
+ facts.totalPiiDetections = 0;
886
+ facts.lastError = "";
887
+ facts.checkpoints = [];
888
+ timeline.length = 0;
889
+ },
890
+ },
891
+
892
+ // ============================================================================
893
+ // Constraints (6 with priority + after ordering)
894
+ // ============================================================================
895
+
896
+ constraints: {
897
+ normalizeNeeded: {
898
+ priority: 100,
899
+ when: (facts) => {
900
+ return facts.stage === "ingesting" && facts.flagEvents.length > 0;
901
+ },
902
+ require: { type: "NORMALIZE_EVENTS" },
903
+ },
904
+
905
+ groupingNeeded: {
906
+ priority: 90,
907
+ after: ["normalizeNeeded"],
908
+ when: (facts) => {
909
+ return facts.flagEvents.some((e) => !e.grouped);
910
+ },
911
+ require: { type: "GROUP_EVENTS" },
912
+ },
913
+
914
+ enrichmentNeeded: {
915
+ priority: 80,
916
+ after: ["groupingNeeded"],
917
+ when: (facts) => {
918
+ return facts.cases.some((c) => !c.enriched && c.signals.length < 3);
919
+ },
920
+ require: (facts) => {
921
+ const target = facts.cases.find(
922
+ (c) => !c.enriched && c.signals.length < 3,
923
+ );
924
+
925
+ return { type: "ENRICH_CASE", caseId: target?.id ?? "" };
926
+ },
927
+ },
928
+
929
+ analysisNeeded: {
930
+ priority: 70,
931
+ after: ["enrichmentNeeded"],
932
+ when: (facts) => {
933
+ return (
934
+ facts.analysisBudget > 0 &&
935
+ facts.cases.some((c) => c.enriched && !c.analyzed)
936
+ );
937
+ },
938
+ require: (facts) => {
939
+ const target = facts.cases.find((c) => c.enriched && !c.analyzed);
940
+
941
+ return { type: "ANALYZE_CASE", caseId: target?.id ?? "" };
942
+ },
943
+ },
944
+
945
+ humanReviewNeeded: {
946
+ priority: 65,
947
+ after: ["analysisNeeded"],
948
+ when: (facts) => {
949
+ return facts.cases.some(
950
+ (c) =>
951
+ c.analyzed &&
952
+ c.riskScore > facts.riskThreshold &&
953
+ c.disposition === "pending",
954
+ );
955
+ },
956
+ require: (facts) => {
957
+ const target = facts.cases.find(
958
+ (c) =>
959
+ c.analyzed &&
960
+ c.riskScore > facts.riskThreshold &&
961
+ c.disposition === "pending",
962
+ );
963
+
964
+ return { type: "HUMAN_REVIEW", caseId: target?.id ?? "" };
965
+ },
966
+ },
967
+
968
+ budgetEscalation: {
969
+ priority: 60,
970
+ when: (facts) => {
971
+ return (
972
+ facts.analysisBudget <= 0 &&
973
+ facts.cases.some(
974
+ (c) => c.enriched && !c.analyzed && c.disposition === "pending",
975
+ )
976
+ );
977
+ },
978
+ require: (facts) => {
979
+ const target = facts.cases.find(
980
+ (c) => c.enriched && !c.analyzed && c.disposition === "pending",
981
+ );
982
+
983
+ return { type: "ESCALATE", caseId: target?.id ?? "" };
984
+ },
985
+ },
986
+ },
987
+
988
+ // ============================================================================
989
+ // Resolvers (6)
990
+ // ============================================================================
991
+
992
+ resolvers: {
993
+ normalizeEvents: {
994
+ requirement: "NORMALIZE_EVENTS",
995
+ resolve: async (_req, context) => {
996
+ addTimeline("stage", "normalizing events");
997
+
998
+ const events = [...context.facts.flagEvents];
999
+ let piiCount = 0;
1000
+
1001
+ for (let i = 0; i < events.length; i++) {
1002
+ const event = events[i];
1003
+
1004
+ // Run PII detection on merchant + memo fields
1005
+ const merchantResult = await detectPII(event.merchant, {
1006
+ types: ["credit_card", "bank_account", "ssn"],
1007
+ });
1008
+ const memoResult = await detectPII(event.memo, {
1009
+ types: ["credit_card", "bank_account", "ssn"],
1010
+ });
1011
+
1012
+ const hasPii = merchantResult.detected || memoResult.detected;
1013
+ if (hasPii) {
1014
+ piiCount++;
1015
+ }
1016
+
1017
+ events[i] = {
1018
+ ...event,
1019
+ piiFound: hasPii,
1020
+ redactedMerchant: merchantResult.detected
1021
+ ? redactPII(event.merchant, merchantResult.items, "typed")
1022
+ : event.merchant,
1023
+ redactedMemo: memoResult.detected
1024
+ ? redactPII(event.memo, memoResult.items, "typed")
1025
+ : event.memo,
1026
+ };
1027
+ }
1028
+
1029
+ // Simulate processing delay (before fact mutations to avoid
1030
+ // mid-resolver reconcile canceling this resolver)
1031
+ await delay(300);
1032
+
1033
+ // All fact mutations at the end — no more awaits after this
1034
+ context.facts.stage = "normalizing";
1035
+ context.facts.flagEvents = events;
1036
+ context.facts.totalPiiDetections =
1037
+ context.facts.totalPiiDetections + piiCount;
1038
+ },
1039
+ },
1040
+
1041
+ groupEvents: {
1042
+ requirement: "GROUP_EVENTS",
1043
+ resolve: async (_req, context) => {
1044
+ addTimeline("stage", "grouping events into cases");
1045
+
1046
+ const events = [...context.facts.flagEvents];
1047
+ const existingCases = [...context.facts.cases];
1048
+
1049
+ // Group by accountId
1050
+ const groups = new Map<string, FlagEvent[]>();
1051
+ for (const event of events) {
1052
+ if (event.grouped) {
1053
+ continue;
1054
+ }
1055
+
1056
+ const existing = groups.get(event.accountId) ?? [];
1057
+ existing.push(event);
1058
+ groups.set(event.accountId, existing);
1059
+ }
1060
+
1061
+ // Create cases from groups
1062
+ let caseNum = existingCases.length;
1063
+ for (const [accountId, groupEvents] of groups) {
1064
+ caseNum++;
1065
+ const newCase: FraudCase = {
1066
+ id: `case-${String(caseNum).padStart(3, "0")}`,
1067
+ accountId,
1068
+ events: groupEvents,
1069
+ signals: [],
1070
+ enriched: false,
1071
+ analyzed: false,
1072
+ riskScore: 0,
1073
+ severity: "low",
1074
+ disposition: "pending",
1075
+ };
1076
+ existingCases.push(newCase);
1077
+ }
1078
+
1079
+ // Mark all events as grouped
1080
+ const markedEvents = events.map((e) => ({ ...e, grouped: true }));
1081
+
1082
+ await delay(200);
1083
+
1084
+ // All fact mutations at the end — no more awaits after this
1085
+ context.facts.stage = "grouping";
1086
+ context.facts.flagEvents = markedEvents;
1087
+ context.facts.cases = existingCases;
1088
+ },
1089
+ },
1090
+
1091
+ enrichCase: {
1092
+ requirement: "ENRICH_CASE",
1093
+ key: (req) => `enrich-${req.caseId}`,
1094
+ retry: { attempts: 2, backoff: "exponential" },
1095
+ resolve: async (req, context) => {
1096
+ addTimeline("stage", `enriching ${req.caseId}`);
1097
+
1098
+ const cases = [...context.facts.cases];
1099
+ const idx = cases.findIndex((c) => c.id === req.caseId);
1100
+ if (idx < 0) {
1101
+ return;
1102
+ }
1103
+
1104
+ const signals = getMockEnrichment(cases[idx].accountId);
1105
+
1106
+ // Simulate API call
1107
+ await delay(400);
1108
+
1109
+ // All fact mutations at the end — no more awaits after this
1110
+ cases[idx] = {
1111
+ ...cases[idx],
1112
+ signals,
1113
+ enriched: true,
1114
+ };
1115
+ context.facts.stage = "enriching";
1116
+ context.facts.cases = cases;
1117
+ },
1118
+ },
1119
+
1120
+ analyzeCase: {
1121
+ requirement: "ANALYZE_CASE",
1122
+ key: (req) => `analyze-${req.caseId}`,
1123
+ retry: { attempts: 1, backoff: "none" },
1124
+ resolve: async (req, context) => {
1125
+ addTimeline("stage", `analyzing ${req.caseId}`);
1126
+
1127
+ const cases = [...context.facts.cases];
1128
+ const idx = cases.findIndex((c) => c.id === req.caseId);
1129
+ if (idx < 0) {
1130
+ return;
1131
+ }
1132
+
1133
+ const fraudCase = cases[idx];
1134
+
1135
+ // Consume budget
1136
+ const cost = 25 + Math.floor(fraudCase.events.length * 5);
1137
+
1138
+ // Deterministic analysis
1139
+ await delay(500);
1140
+ const result = analyzeWithFormula(fraudCase);
1141
+ if (
1142
+ result.disposition === "pending" &&
1143
+ result.riskScore <= context.facts.riskThreshold
1144
+ ) {
1145
+ result.disposition = "flagged";
1146
+ result.analysisNotes +=
1147
+ " Auto-flagged: below human review threshold.";
1148
+ }
1149
+
1150
+ // All fact mutations at the end — no more awaits after this
1151
+ cases[idx] = { ...fraudCase, ...result, analyzed: true };
1152
+ context.facts.stage = "analyzing";
1153
+ context.facts.analysisBudget = Math.max(
1154
+ 0,
1155
+ context.facts.analysisBudget - cost,
1156
+ );
1157
+ context.facts.cases = cases;
1158
+ },
1159
+ },
1160
+
1161
+ humanReview: {
1162
+ requirement: "HUMAN_REVIEW",
1163
+ resolve: async (req, context) => {
1164
+ addTimeline("info", `${req.caseId} sent to human review`);
1165
+
1166
+ const cases = [...context.facts.cases];
1167
+ const idx = cases.findIndex((c) => c.id === req.caseId);
1168
+ if (idx < 0) {
1169
+ return;
1170
+ }
1171
+
1172
+ await delay(100);
1173
+
1174
+ cases[idx] = {
1175
+ ...cases[idx],
1176
+ disposition: "human_review",
1177
+ dispositionReason: "Risk score exceeds threshold",
1178
+ };
1179
+ context.facts.cases = cases;
1180
+ },
1181
+ },
1182
+
1183
+ escalate: {
1184
+ requirement: "ESCALATE",
1185
+ resolve: async (req, context) => {
1186
+ addTimeline("info", `${req.caseId} escalated (budget exhausted)`);
1187
+
1188
+ const cases = [...context.facts.cases];
1189
+ const idx = cases.findIndex((c) => c.id === req.caseId);
1190
+ if (idx < 0) {
1191
+ return;
1192
+ }
1193
+
1194
+ await delay(100);
1195
+
1196
+ cases[idx] = {
1197
+ ...cases[idx],
1198
+ disposition: "escalated",
1199
+ dispositionReason: "Analysis budget exhausted",
1200
+ };
1201
+ context.facts.cases = cases;
1202
+ },
1203
+ },
1204
+ },
1205
+
1206
+ // ============================================================================
1207
+ // Effects (3)
1208
+ // ============================================================================
1209
+
1210
+ effects: {
1211
+ logStageChange: {
1212
+ deps: ["stage"],
1213
+ run: (facts, prev) => {
1214
+ if (prev && prev.stage !== facts.stage) {
1215
+ addTimeline("stage", `${prev.stage} → ${facts.stage}`);
1216
+ }
1217
+ },
1218
+ },
1219
+
1220
+ logPiiDetection: {
1221
+ deps: ["totalPiiDetections"],
1222
+ run: (facts, prev) => {
1223
+ if (prev && facts.totalPiiDetections !== prev.totalPiiDetections) {
1224
+ addTimeline(
1225
+ "pii",
1226
+ `PII guardrail fired (${facts.totalPiiDetections} total detections)`,
1227
+ );
1228
+ }
1229
+ },
1230
+ },
1231
+
1232
+ logBudgetWarning: {
1233
+ deps: ["analysisBudget"],
1234
+ run: (facts, prev) => {
1235
+ if (prev && prev.analysisBudget > 0 && facts.analysisBudget <= 0) {
1236
+ addTimeline("budget", "analysis budget exhausted");
1237
+ }
1238
+ },
1239
+ },
1240
+ },
1241
+ });
1242
+
1243
+ // ============================================================================
1244
+ // System
1245
+ // ============================================================================
1246
+
1247
+ export const system = createSystem({
1248
+ module: fraudAnalysisModule,
1249
+ plugins: [devtoolsPlugin({ name: "fraud-analysis", panel: true })],
1250
+ history: { maxSnapshots: 50 },
1251
+ trace: { maxRuns: 100 },
1252
+ });
1253
+
1254
+ // ============================================================================
1255
+ // Helpers
1256
+ // ============================================================================
1257
+
1258
+ export function delay(ms: number): Promise<void> {
1259
+ return new Promise((resolve) => setTimeout(resolve, ms));
1260
+ }
1261
+ ```