@plures/praxis 1.2.13 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +44 -0
  2. package/dist/browser/chunk-MJK3IYTJ.js +384 -0
  3. package/dist/browser/{chunk-K377RW4V.js → chunk-N63K4KWS.js} +1 -1
  4. package/dist/browser/{engine-YJZV4SLD.js → engine-YIEGSX7U.js} +1 -1
  5. package/dist/browser/index.d.ts +104 -2
  6. package/dist/browser/index.js +188 -7
  7. package/dist/browser/integrations/svelte.d.ts +2 -2
  8. package/dist/browser/integrations/svelte.js +2 -2
  9. package/dist/browser/{reactive-engine.svelte-9aS0kTa8.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +139 -5
  10. package/dist/node/{chunk-PRPQO6R5.js → chunk-5JQJZADT.js} +1 -1
  11. package/dist/node/chunk-KMJWAFZV.js +389 -0
  12. package/dist/node/{chunk-5RH7UAQC.js → chunk-PTH6MD6P.js} +1 -0
  13. package/dist/node/cli/index.cjs +1553 -839
  14. package/dist/node/cli/index.js +39 -2
  15. package/dist/node/cloud/index.d.cts +1 -1
  16. package/dist/node/cloud/index.d.ts +1 -1
  17. package/dist/node/components/index.d.cts +2 -2
  18. package/dist/node/components/index.d.ts +2 -2
  19. package/dist/node/conversations-KQBXTP3N.js +596 -0
  20. package/dist/node/{engine-2DQBKBJC.js → engine-FEN5IYZ5.js} +1 -1
  21. package/dist/node/index.cjs +911 -43
  22. package/dist/node/index.d.cts +574 -7
  23. package/dist/node/index.d.ts +574 -7
  24. package/dist/node/index.js +672 -26
  25. package/dist/node/integrations/svelte.cjs +190 -3
  26. package/dist/node/integrations/svelte.d.cts +3 -3
  27. package/dist/node/integrations/svelte.d.ts +3 -3
  28. package/dist/node/integrations/svelte.js +2 -2
  29. package/dist/node/{protocol-Qek7ebBl.d.ts → protocol-DcyGMmWY.d.cts} +8 -1
  30. package/dist/node/{protocol-Qek7ebBl.d.cts → protocol-DcyGMmWY.d.ts} +8 -1
  31. package/dist/node/{reactive-engine.svelte-CRNqHlbv.d.ts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +145 -6
  32. package/dist/node/{reactive-engine.svelte-BFIZfawz.d.cts → reactive-engine.svelte-DekxqFu0.d.ts} +145 -6
  33. package/dist/node/{terminal-adapter-B-UK_Vdz.d.ts → terminal-adapter-CvIvgTo4.d.ts} +1 -1
  34. package/dist/node/{terminal-adapter-BQSIF5bf.d.cts → terminal-adapter-Db-snPJ3.d.cts} +1 -1
  35. package/dist/node/{validate-CNHUULQE.js → validate-EN3M4FUR.js} +1 -1
  36. package/dist/node/{verify-KLJRXVJS.js → verify-7VZRP2WS.js} +2 -2
  37. package/docs/BOT_UPDATE_POLICY.md +125 -0
  38. package/docs/DOGFOODING_CHECKLIST.md +254 -0
  39. package/docs/DOGFOODING_INDEX.md +169 -0
  40. package/docs/DOGFOODING_QUICK_START.md +140 -0
  41. package/docs/KNO_ENG_EXTRACTION_PLAN.md +577 -0
  42. package/docs/PLURES_TOOLS_INVENTORY.md +170 -0
  43. package/docs/README.md +12 -0
  44. package/docs/TESTING_BOT_WORKFLOWS.md +154 -0
  45. package/docs/conversations/INTEGRATION_POINTS.md +719 -0
  46. package/docs/conversations/README.md +168 -0
  47. package/docs/core/extending-praxis-core.md +604 -0
  48. package/docs/core/praxis-core-api.md +385 -0
  49. package/docs/decision-ledger/contract-index.json +2 -2
  50. package/docs/decision-ledger/decisions/2026-02-01-monorepo-organization.md +130 -0
  51. package/docs/examples/DOGFOODING_WORKFLOW_EXAMPLE.md +295 -0
  52. package/docs/examples/README.md +41 -0
  53. package/docs/workflows/pr-overlap-guard.md +50 -0
  54. package/package.json +8 -3
  55. package/src/__tests__/chronicle.test.ts +512 -0
  56. package/src/__tests__/conversations.test.ts +312 -0
  57. package/src/__tests__/edge-cases.test.ts +1 -1
  58. package/src/__tests__/engine-dx.test.ts +355 -0
  59. package/src/__tests__/engine-v2.test.ts +532 -0
  60. package/src/cli/commands/conversations.ts +252 -0
  61. package/src/cli/index.ts +73 -0
  62. package/src/conversations/README.md +230 -0
  63. package/src/conversations/candidate.schema.json +123 -0
  64. package/src/conversations/candidates.ts +114 -0
  65. package/src/conversations/capture.ts +56 -0
  66. package/src/conversations/classify.ts +110 -0
  67. package/src/conversations/conversation.schema.json +106 -0
  68. package/src/conversations/emitters/fs.ts +65 -0
  69. package/src/conversations/emitters/github.ts +115 -0
  70. package/src/conversations/gate.ts +102 -0
  71. package/src/conversations/index.ts +28 -0
  72. package/src/conversations/normalize.ts +51 -0
  73. package/src/conversations/redact.ts +57 -0
  74. package/src/conversations/types.ts +96 -0
  75. package/src/core/chronicle/chronicle.ts +227 -0
  76. package/src/core/chronicle/context.ts +80 -0
  77. package/src/core/chronicle/index.ts +53 -0
  78. package/src/core/chronicle/mcp.ts +135 -0
  79. package/src/core/chronicle/types.ts +61 -0
  80. package/src/core/completeness.ts +274 -0
  81. package/src/core/engine.ts +143 -3
  82. package/src/core/pluresdb/index.ts +22 -0
  83. package/src/core/pluresdb/store.ts +171 -8
  84. package/src/core/protocol.ts +7 -0
  85. package/src/core/rule-result.ts +130 -0
  86. package/src/core/rules.ts +24 -5
  87. package/src/core/ui-rules.ts +340 -0
  88. package/src/dsl/index.ts +6 -0
  89. package/src/index.ts +45 -0
  90. package/src/integrations/pluresdb.ts +22 -0
  91. package/src/vite/completeness-plugin.ts +72 -0
  92. package/dist/browser/chunk-VOMLVI6V.js +0 -197
  93. package/dist/node/chunk-VOMLVI6V.js +0 -197
@@ -11,6 +11,8 @@ declare const process: { env: { [key: string]: string | undefined } } | undefine
11
11
  import type { PraxisDB, UnsubscribeFn } from './adapter.js';
12
12
  import type { PraxisRegistry } from '../rules.js';
13
13
  import type { PraxisFact, PraxisEvent, PraxisState } from '../protocol.js';
14
+ import type { Chronicle } from '../chronicle/chronicle.js';
15
+ import { ChronicleContext } from '../chronicle/context.js';
14
16
 
15
17
  /**
16
18
  * Key paths for Praxis data in PluresDB
@@ -110,6 +112,7 @@ export class PraxisDBStore<TContext = unknown> {
110
112
  private subscriptions: UnsubscribeFn[] = [];
111
113
  private factWatchers = new Map<string, Set<(facts: PraxisFact[]) => void>>();
112
114
  private onRuleError: RuleErrorHandler;
115
+ private chronicle?: Chronicle;
113
116
 
114
117
  constructor(options: PraxisDBStoreOptions<TContext> & { onRuleError?: RuleErrorHandler }) {
115
118
  this.db = options.db;
@@ -118,6 +121,26 @@ export class PraxisDBStore<TContext = unknown> {
118
121
  this.onRuleError = options.onRuleError ?? defaultErrorHandler;
119
122
  }
120
123
 
124
+ /**
125
+ * Attach a Chronicle observer to this store.
126
+ *
127
+ * Every subsequent `storeFact` and `appendEvent` call will be recorded as a
128
+ * causal graph node in PluresDB, enabling full observability for free.
129
+ *
130
+ * @param chronicle Chronicle implementation to attach
131
+ * @returns `this` for fluent chaining
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const store = createPraxisDBStore(db, registry)
136
+ * .withChronicle(createChronicle(db));
137
+ * ```
138
+ */
139
+ withChronicle(chronicle: Chronicle): this {
140
+ this.chronicle = chronicle;
141
+ return this;
142
+ }
143
+
121
144
  /**
122
145
  * Store a fact in PluresDB
123
146
  *
@@ -134,8 +157,37 @@ export class PraxisDBStore<TContext = unknown> {
134
157
  throw new Error(`Constraint violation: ${constraintResult.errors.join(', ')}`);
135
158
  }
136
159
 
160
+ // Capture before state for Chronicle (best-effort; ignore errors)
161
+ let before: unknown;
162
+ if (this.chronicle) {
163
+ const payload = fact.payload as Record<string, unknown> | undefined;
164
+ const id = payload?.id as string | undefined;
165
+ if (id) {
166
+ before = await this.getFact(fact.tag, id);
167
+ }
168
+ }
169
+
137
170
  await this.persistFact(fact);
138
171
 
172
+ // Record state transition in Chronicle
173
+ if (this.chronicle) {
174
+ const payload = fact.payload as Record<string, unknown> | undefined;
175
+ const id = (payload?.id as string | undefined) ?? '';
176
+ const span = ChronicleContext.current;
177
+ try {
178
+ await this.chronicle.record({
179
+ path: getFactPath(fact.tag, id),
180
+ before,
181
+ after: fact,
182
+ cause: span?.spanId,
183
+ context: span?.contextId,
184
+ metadata: { factTag: fact.tag, operation: 'storeFact' },
185
+ });
186
+ } catch {
187
+ // Chronicle errors must never fail the primary operation
188
+ }
189
+ }
190
+
139
191
  // Trigger rule evaluation - facts stored directly may trigger derived computations
140
192
  await this.triggerRules([fact]);
141
193
  }
@@ -153,7 +205,36 @@ export class PraxisDBStore<TContext = unknown> {
153
205
  }
154
206
 
155
207
  for (const fact of facts) {
208
+ // Capture before state for Chronicle (best-effort)
209
+ let before: unknown;
210
+ if (this.chronicle) {
211
+ const payload = fact.payload as Record<string, unknown> | undefined;
212
+ const id = payload?.id as string | undefined;
213
+ if (id) {
214
+ before = await this.getFact(fact.tag, id);
215
+ }
216
+ }
217
+
156
218
  await this.persistFact(fact);
219
+
220
+ // Record state transition in Chronicle
221
+ if (this.chronicle) {
222
+ const payload = fact.payload as Record<string, unknown> | undefined;
223
+ const id = (payload?.id as string | undefined) ?? '';
224
+ const span = ChronicleContext.current;
225
+ try {
226
+ await this.chronicle.record({
227
+ path: getFactPath(fact.tag, id),
228
+ before,
229
+ after: fact,
230
+ cause: span?.spanId,
231
+ context: span?.contextId,
232
+ metadata: { factTag: fact.tag, operation: 'storeFacts' },
233
+ });
234
+ } catch {
235
+ // Chronicle errors must never fail the primary operation
236
+ }
237
+ }
157
238
  }
158
239
 
159
240
  // Trigger rule evaluation
@@ -207,8 +288,37 @@ export class PraxisDBStore<TContext = unknown> {
207
288
  const newEvents = [...existingEvents, entry];
208
289
  await this.db.set(path, newEvents);
209
290
 
210
- // Trigger rules with this event
211
- await this.triggerRulesForEvents([event]);
291
+ // Record event in Chronicle and capture node ID so derived facts can link back to it
292
+ let eventNodeId: string | undefined;
293
+ if (this.chronicle) {
294
+ const span = ChronicleContext.current;
295
+ try {
296
+ const node = await this.chronicle.record({
297
+ path,
298
+ before: existingEvents.length > 0 ? existingEvents[existingEvents.length - 1] : undefined,
299
+ after: entry,
300
+ cause: span?.spanId,
301
+ context: span?.contextId,
302
+ metadata: { eventTag: event.tag, sequence: String(entry.sequence), operation: 'appendEvent' },
303
+ });
304
+ eventNodeId = node.id;
305
+ } catch {
306
+ // Chronicle errors must never fail the primary operation
307
+ }
308
+ }
309
+
310
+ // Trigger rules within a causal span attributed to this event node,
311
+ // so any derived facts automatically link back to this event via 'causes' edges.
312
+ const outerSpan = ChronicleContext.current;
313
+ const ruleSpan = eventNodeId
314
+ ? { spanId: eventNodeId, contextId: outerSpan?.contextId }
315
+ : outerSpan;
316
+
317
+ if (ruleSpan && this.chronicle) {
318
+ await ChronicleContext.runAsync(ruleSpan, () => this.triggerRulesForEvents([event]));
319
+ } else {
320
+ await this.triggerRulesForEvents([event]);
321
+ }
212
322
  }
213
323
 
214
324
  /**
@@ -224,7 +334,8 @@ export class PraxisDBStore<TContext = unknown> {
224
334
  eventsByTag.set(event.tag, [...existing, event]);
225
335
  }
226
336
 
227
- // Append each group
337
+ // Append each group and collect the last recorded event node ID for causal linking
338
+ let lastEventNodeId: string | undefined;
228
339
  for (const [tag, tagEvents] of eventsByTag) {
229
340
  const path = getEventPath(tag);
230
341
  const existingEvents = (await this.db.get<EventStreamEntry[]>(path)) ?? [];
@@ -237,10 +348,38 @@ export class PraxisDBStore<TContext = unknown> {
237
348
  }));
238
349
 
239
350
  await this.db.set(path, [...existingEvents, ...newEntries]);
351
+
352
+ // Record each appended event in Chronicle
353
+ if (this.chronicle) {
354
+ const span = ChronicleContext.current;
355
+ for (const entry of newEntries) {
356
+ try {
357
+ const node = await this.chronicle.record({
358
+ path,
359
+ after: entry,
360
+ cause: span?.spanId,
361
+ context: span?.contextId,
362
+ metadata: { eventTag: tag, sequence: String(entry.sequence), operation: 'appendEvents' },
363
+ });
364
+ lastEventNodeId = node.id;
365
+ } catch {
366
+ // Chronicle errors must never fail the primary operation
367
+ }
368
+ }
369
+ }
240
370
  }
241
371
 
242
- // Trigger rules
243
- await this.triggerRulesForEvents(events);
372
+ // Trigger rules in a causal span attributed to the last event node
373
+ const outerSpan = ChronicleContext.current;
374
+ const ruleSpan = lastEventNodeId
375
+ ? { spanId: lastEventNodeId, contextId: outerSpan?.contextId }
376
+ : outerSpan;
377
+
378
+ if (ruleSpan && this.chronicle) {
379
+ await ChronicleContext.runAsync(ruleSpan, () => this.triggerRulesForEvents(events));
380
+ } else {
381
+ await this.triggerRulesForEvents(events);
382
+ }
244
383
  }
245
384
 
246
385
  /**
@@ -363,9 +502,10 @@ export class PraxisDBStore<TContext = unknown> {
363
502
  const rules = this.registry.getAllRules();
364
503
 
365
504
  // Build state for rule evaluation
366
- const state: PraxisState & { context: TContext } = {
505
+ const state: PraxisState & { context: TContext; events: PraxisEvent[] } = {
367
506
  context: this.context,
368
507
  facts: [],
508
+ events,
369
509
  meta: {},
370
510
  };
371
511
 
@@ -373,8 +513,13 @@ export class PraxisDBStore<TContext = unknown> {
373
513
  const derivedFacts: PraxisFact[] = [];
374
514
  for (const rule of rules) {
375
515
  try {
376
- const facts = rule.impl(state, events);
377
- derivedFacts.push(...facts);
516
+ const result = rule.impl(state, events);
517
+ if (Array.isArray(result)) {
518
+ derivedFacts.push(...result);
519
+ } else if (result && 'kind' in result && result.kind === 'emit') {
520
+ derivedFacts.push(...(result as any).facts);
521
+ }
522
+ // noop/skip/retract handled by engine, not store
378
523
  } catch (error) {
379
524
  this.onRuleError(rule.id, error);
380
525
  }
@@ -386,6 +531,24 @@ export class PraxisDBStore<TContext = unknown> {
386
531
  if (constraintResult.valid) {
387
532
  for (const fact of derivedFacts) {
388
533
  await this.persistFact(fact);
534
+
535
+ // Record derived fact in Chronicle using the current causal span
536
+ if (this.chronicle) {
537
+ const payload = fact.payload as Record<string, unknown> | undefined;
538
+ const id = (payload?.id as string | undefined) ?? '';
539
+ const span = ChronicleContext.current;
540
+ try {
541
+ await this.chronicle.record({
542
+ path: getFactPath(fact.tag, id),
543
+ after: fact,
544
+ cause: span?.spanId,
545
+ context: span?.contextId,
546
+ metadata: { factTag: fact.tag, operation: 'derivedFact' },
547
+ });
548
+ } catch {
549
+ // Chronicle errors must never fail the primary operation
550
+ }
551
+ }
389
552
  }
390
553
  }
391
554
  }
@@ -75,6 +75,13 @@ export interface PraxisState {
75
75
  context: unknown;
76
76
  /** Current facts about the domain */
77
77
  facts: PraxisFact[];
78
+ /**
79
+ * Events currently being processed in this step.
80
+ * Available to rules during execution — guaranteed to contain the exact
81
+ * events passed to step()/stepWithContext().
82
+ * Empty outside of step execution.
83
+ */
84
+ events?: PraxisEvent[];
78
85
  /** Optional metadata (timestamps, version, etc.) */
79
86
  meta?: Record<string, unknown>;
80
87
  /** Protocol version (for cross-language compatibility) */
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Typed Rule Results
3
+ *
4
+ * Rules must always return a RuleResult — never an empty array.
5
+ * A rule that has nothing to say returns RuleResult.noop().
6
+ * This makes every rule evaluation traceable and eliminates
7
+ * the ambiguity of "did the rule run but produce nothing,
8
+ * or did it not run at all?"
9
+ */
10
+
11
+ import type { PraxisFact } from './protocol.js';
12
+
13
+ /**
14
+ * The result of evaluating a rule. Every rule MUST return one of:
15
+ * - `RuleResult.emit(facts)` — rule produced facts
16
+ * - `RuleResult.noop(reason?)` — rule evaluated but had nothing to say
17
+ * - `RuleResult.skip(reason?)` — rule decided to skip (preconditions not met)
18
+ * - `RuleResult.retract(tags)` — rule retracts previously emitted facts
19
+ */
20
+ export class RuleResult {
21
+ /** The kind of result */
22
+ readonly kind: 'emit' | 'noop' | 'skip' | 'retract';
23
+ /** Facts produced (only for 'emit') */
24
+ readonly facts: PraxisFact[];
25
+ /** Fact tags to retract (only for 'retract') */
26
+ readonly retractTags: string[];
27
+ /** Optional reason (for noop/skip/retract — useful for debugging) */
28
+ readonly reason?: string;
29
+ /** The rule ID that produced this result (set by engine) */
30
+ ruleId?: string;
31
+
32
+ private constructor(
33
+ kind: 'emit' | 'noop' | 'skip' | 'retract',
34
+ facts: PraxisFact[],
35
+ retractTags: string[],
36
+ reason?: string,
37
+ ) {
38
+ this.kind = kind;
39
+ this.facts = facts;
40
+ this.retractTags = retractTags;
41
+ this.reason = reason;
42
+ }
43
+
44
+ /**
45
+ * Rule produced facts.
46
+ *
47
+ * @example
48
+ * return RuleResult.emit([
49
+ * { tag: 'sprint.behind', payload: { deficit: 5 } }
50
+ * ]);
51
+ */
52
+ static emit(facts: PraxisFact[]): RuleResult {
53
+ if (facts.length === 0) {
54
+ throw new Error(
55
+ 'RuleResult.emit() requires at least one fact. ' +
56
+ 'Use RuleResult.noop() or RuleResult.skip() when a rule has nothing to say.'
57
+ );
58
+ }
59
+ return new RuleResult('emit', facts, []);
60
+ }
61
+
62
+ /**
63
+ * Rule evaluated but had nothing to report.
64
+ * Unlike returning [], this is explicit and traceable.
65
+ *
66
+ * @example
67
+ * if (ctx.completedHours >= expectedHours) {
68
+ * return RuleResult.noop('Sprint is on pace');
69
+ * }
70
+ */
71
+ static noop(reason?: string): RuleResult {
72
+ return new RuleResult('noop', [], [], reason);
73
+ }
74
+
75
+ /**
76
+ * Rule decided to skip because preconditions were not met.
77
+ * Distinct from noop: skip means "I can't evaluate", noop means "I evaluated and found nothing".
78
+ *
79
+ * @example
80
+ * if (!ctx.sprintName) {
81
+ * return RuleResult.skip('No active sprint');
82
+ * }
83
+ */
84
+ static skip(reason?: string): RuleResult {
85
+ return new RuleResult('skip', [], [], reason);
86
+ }
87
+
88
+ /**
89
+ * Rule retracts previously emitted facts by tag.
90
+ * Used when a condition that previously produced facts is no longer true.
91
+ *
92
+ * @example
93
+ * // Sprint was behind, but caught up
94
+ * if (ctx.completedHours >= expectedHours) {
95
+ * return RuleResult.retract(['sprint.behind'], 'Sprint caught up');
96
+ * }
97
+ */
98
+ static retract(tags: string[], reason?: string): RuleResult {
99
+ if (tags.length === 0) {
100
+ throw new Error('RuleResult.retract() requires at least one tag.');
101
+ }
102
+ return new RuleResult('retract', [], tags, reason);
103
+ }
104
+
105
+ /** Whether this result produced facts */
106
+ get hasFacts(): boolean {
107
+ return this.facts.length > 0;
108
+ }
109
+
110
+ /** Whether this result retracts facts */
111
+ get hasRetractions(): boolean {
112
+ return this.retractTags.length > 0;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * A rule function that returns a typed RuleResult.
118
+ * New API — replaces the old PraxisFact[] return type.
119
+ */
120
+ export type TypedRuleFn<TContext = unknown> = (
121
+ state: import('./protocol.js').PraxisState & { context: TContext; events: import('./protocol.js').PraxisEvent[] },
122
+ events: import('./protocol.js').PraxisEvent[]
123
+ ) => RuleResult;
124
+
125
+ /**
126
+ * Convenience: create a fact object (just a shorthand)
127
+ */
128
+ export function fact(tag: string, payload: unknown): PraxisFact {
129
+ return { tag, payload };
130
+ }
package/src/core/rules.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { PraxisEvent, PraxisFact, PraxisState } from './protocol.js';
10
10
  import type { Contract, ContractGap, MissingArtifact, Severity } from '../decision-ledger/types.js';
11
+ import type { RuleResult } from './rule-result.js';
11
12
 
12
13
  declare const process:
13
14
  | {
@@ -31,14 +32,20 @@ export type ConstraintId = string;
31
32
  * A rule function derives new facts or transitions from context + input facts/events.
32
33
  * Rules must be pure - no side effects.
33
34
  *
34
- * @param state Current Praxis state
35
- * @param events Events to process
36
- * @returns Array of new facts to add to the state
35
+ * Returns either:
36
+ * - `RuleResult` (new API — typed, traceable, supports retraction)
37
+ * - `PraxisFact[]` (legacy backward compatible, will be deprecated)
38
+ *
39
+ * The state parameter includes `events` — the current batch being processed.
40
+ *
41
+ * @param state Current Praxis state (includes state.events for current batch)
42
+ * @param events Events to process (same as state.events, provided for convenience)
43
+ * @returns RuleResult or array of new facts
37
44
  */
38
45
  export type RuleFn<TContext = unknown> = (
39
- state: PraxisState & { context: TContext },
46
+ state: PraxisState & { context: TContext; events: PraxisEvent[] },
40
47
  events: PraxisEvent[]
41
- ) => PraxisFact[];
48
+ ) => RuleResult | PraxisFact[];
42
49
 
43
50
  /**
44
51
  * A constraint function checks that an invariant holds.
@@ -61,6 +68,18 @@ export interface RuleDescriptor<TContext = unknown> {
61
68
  description: string;
62
69
  /** Implementation function */
63
70
  impl: RuleFn<TContext>;
71
+ /**
72
+ * Optional event type filter — only evaluate this rule when at least one
73
+ * event in the batch has a matching `tag`. When omitted, the rule runs on
74
+ * every step (catch-all).
75
+ *
76
+ * Accepts a single tag string or an array of tags.
77
+ *
78
+ * @example
79
+ * { id: 'sprint-behind', eventTypes: ['sprint.update'], impl: ... }
80
+ * { id: 'note-check', eventTypes: 'note.update', impl: ... }
81
+ */
82
+ eventTypes?: string | string[];
64
83
  /** Optional contract for rule behavior */
65
84
  contract?: Contract;
66
85
  /** Optional metadata */