@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
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Chronicle Types
3
+ *
4
+ * Core types for the Chronicle causal tracking system.
5
+ * Records state transitions as a causal graph stored in PluresDB.
6
+ */
7
+
8
+ /**
9
+ * Direction for traversing causal chains.
10
+ */
11
+ export type TraceDirection = 'backward' | 'forward' | 'both';
12
+
13
+ /**
14
+ * Causal relationship type between Chronicle nodes.
15
+ * - `causes`: node A caused node B (explicit causal link)
16
+ * - `context`: node B belongs to the same session/request as node A
17
+ * - `follows`: node B happened after node A in the same context
18
+ */
19
+ export type EdgeType = 'causes' | 'context' | 'follows';
20
+
21
+ /**
22
+ * A recorded state transition event passed to Chronicle.
23
+ */
24
+ export interface ChronicleEvent {
25
+ /** Path to the changed value (fact or event stream path) */
26
+ path: string;
27
+ /** Value before the change (undefined for creates) */
28
+ before?: unknown;
29
+ /** Value after the change */
30
+ after?: unknown;
31
+ /** Parent span/node ID that caused this change */
32
+ cause?: string;
33
+ /** Session or request ID grouping related changes */
34
+ context?: string;
35
+ /** Additional metadata key-value pairs */
36
+ metadata: Record<string, string>;
37
+ }
38
+
39
+ /**
40
+ * A Chronicle node representing a single recorded state transition.
41
+ */
42
+ export interface ChronicleNode {
43
+ /** Unique node ID: `chronos:{timestamp}-{counter}` */
44
+ id: string;
45
+ /** Timestamp (ms since epoch) when this node was recorded */
46
+ timestamp: number;
47
+ /** The recorded state transition */
48
+ event: ChronicleEvent;
49
+ }
50
+
51
+ /**
52
+ * A directed edge in the causal graph connecting two Chronicle nodes.
53
+ */
54
+ export interface ChronicleEdge {
55
+ /** Source node ID */
56
+ from: string;
57
+ /** Target node ID */
58
+ to: string;
59
+ /** Type of causal relationship */
60
+ type: EdgeType;
61
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * @plures/praxis — Completeness Analysis
3
+ *
4
+ * This module provides tools to measure and enforce "Praxis Completeness" —
5
+ * the degree to which an application's logic is expressed through Praxis
6
+ * rules, constraints, and contracts rather than scattered conditionals.
7
+ *
8
+ * ## Definition: Praxis Logic Completeness
9
+ *
10
+ * An application is "Praxis Complete" when:
11
+ *
12
+ * 1. **DOMAIN RULES (100%)** — Every business decision that produces user-visible
13
+ * behavior change is expressed as a Praxis rule. "If the sprint is behind pace,
14
+ * show a warning" is domain logic. It belongs in Praxis.
15
+ *
16
+ * 2. **INVARIANTS (100%)** — Every data validity assertion is a Praxis constraint.
17
+ * "Sprint hours must not exceed 80" is an invariant. It belongs in Praxis.
18
+ *
19
+ * 3. **CONTRACTS (>80%)** — Rules that encode non-obvious behavior have contracts
20
+ * (behavior description + examples + invariants). Contracts are documentation
21
+ * AND test vectors — they prove the tool isn't the bug.
22
+ *
23
+ * 4. **CONTEXT COVERAGE (100%)** — Every piece of application state that rules
24
+ * reason about is in the Praxis context. If a rule needs to know about
25
+ * connection status, connection status must be in the context.
26
+ *
27
+ * 5. **EVENT COVERAGE (100%)** — Every state transition that should trigger rule
28
+ * evaluation fires a Praxis event. If notes can be saved, there's a note.save
29
+ * event. If sprint refreshes, there's a sprint.refresh event.
30
+ *
31
+ * ## What Is NOT Praxis Logic
32
+ *
33
+ * - **UI mechanics**: Panel toggle, scroll position, animation state, focus management
34
+ * - **Data transport**: fetch() calls, WebSocket plumbing, file I/O
35
+ * - **Framework wiring**: Svelte subscriptions, onMount, store creation
36
+ * - **Data transformation**: Parsing, formatting, serialization
37
+ * - **Routing/navigation**: URL handling, panel switching (unless it has business rules)
38
+ *
39
+ * The line: "Does this `if` statement encode a business decision or an app invariant?"
40
+ * If yes → Praxis rule/constraint. If no → leave it.
41
+ *
42
+ * ## Measuring Completeness
43
+ *
44
+ * ### Quantitative Metrics
45
+ * - **Rule Coverage**: (domain `if` branches in Praxis) / (total domain `if` branches)
46
+ * - **Constraint Coverage**: (data invariants in Praxis) / (total data invariants)
47
+ * - **Contract Coverage**: (rules with contracts) / (rules that need contracts)
48
+ * - **Context Coverage**: (state fields wired to context) / (state fields rules need)
49
+ * - **Event Coverage**: (state transitions with events) / (state transitions that matter)
50
+ *
51
+ * ### Qualitative Indicators
52
+ * - Can you change a business rule by editing ONE rule definition? (single source of truth)
53
+ * - Can you test a business rule without rendering UI? (Praxis engine is headless)
54
+ * - Can you explain every business rule to a PM by reading the registry? (self-documenting)
55
+ * - Does the PraxisPanel show all active concerns? (observable)
56
+ *
57
+ * ## The Completeness Score
58
+ *
59
+ * ```
60
+ * Score = (
61
+ * rulesCovered / totalDomainBranches * 40 + // Rules are king (40%)
62
+ * constraintsCovered / totalInvariants * 20 + // Invariants matter (20%)
63
+ * contractsCovered / rulesNeedingContracts * 15 + // Contracts prevent bugs (15%)
64
+ * contextFieldsCovered / totalNeeded * 15 + // Context = visibility (15%)
65
+ * eventsCovered / totalTransitions * 10 // Events = reactivity (10%)
66
+ * )
67
+ * ```
68
+ *
69
+ * 90+ = Complete | 70-89 = Good | 50-69 = Partial | <50 = Incomplete
70
+ */
71
+
72
+ // ─── Types ──────────────────────────────────────────────────────────────────
73
+
74
+ export interface LogicBranch {
75
+ /** Source file + line */
76
+ location: string;
77
+ /** The condition expression */
78
+ condition: string;
79
+ /** Classification */
80
+ kind: 'domain' | 'invariant' | 'ui' | 'transport' | 'wiring' | 'transform';
81
+ /** If domain/invariant: the Praxis rule/constraint that covers it, or null */
82
+ coveredBy: string | null;
83
+ /** Human note */
84
+ note?: string;
85
+ }
86
+
87
+ export interface StateField {
88
+ /** Store or source name */
89
+ source: string;
90
+ /** Field path */
91
+ field: string;
92
+ /** Whether it's in the Praxis context */
93
+ inContext: boolean;
94
+ /** Whether any rule references it */
95
+ usedByRule: boolean;
96
+ }
97
+
98
+ export interface StateTransition {
99
+ /** What changes */
100
+ description: string;
101
+ /** The Praxis event tag, or null if missing */
102
+ eventTag: string | null;
103
+ /** Source location */
104
+ location: string;
105
+ }
106
+
107
+ export interface CompletenessReport {
108
+ /** Overall score (0-100) */
109
+ score: number;
110
+ /** Rating */
111
+ rating: 'complete' | 'good' | 'partial' | 'incomplete';
112
+
113
+ rules: {
114
+ total: number;
115
+ covered: number;
116
+ uncovered: LogicBranch[];
117
+ };
118
+ constraints: {
119
+ total: number;
120
+ covered: number;
121
+ uncovered: LogicBranch[];
122
+ };
123
+ contracts: {
124
+ total: number;
125
+ withContracts: number;
126
+ missing: string[];
127
+ };
128
+ context: {
129
+ total: number;
130
+ covered: number;
131
+ missing: StateField[];
132
+ };
133
+ events: {
134
+ total: number;
135
+ covered: number;
136
+ missing: StateTransition[];
137
+ };
138
+ }
139
+
140
+ export interface CompletenessConfig {
141
+ /** Minimum score to pass (default: 90) */
142
+ threshold?: number;
143
+ /** Whether to throw on failure (for CI) */
144
+ strict?: boolean;
145
+ }
146
+
147
+ // ─── Audit Helper ───────────────────────────────────────────────────────────
148
+
149
+ /**
150
+ * Run a completeness audit against a Praxis registry and app manifest.
151
+ *
152
+ * The manifest is a developer-authored declaration of all logic branches,
153
+ * state fields, and state transitions in the app. The auditor checks which
154
+ * ones are covered by Praxis.
155
+ */
156
+ export function auditCompleteness(
157
+ manifest: {
158
+ branches: LogicBranch[];
159
+ stateFields: StateField[];
160
+ transitions: StateTransition[];
161
+ rulesNeedingContracts: string[];
162
+ },
163
+ registryRuleIds: string[],
164
+ registryConstraintIds: string[],
165
+ rulesWithContracts: string[],
166
+ config?: CompletenessConfig,
167
+ ): CompletenessReport {
168
+ const threshold = config?.threshold ?? 90;
169
+
170
+ // Rules
171
+ const domainBranches = manifest.branches.filter(b => b.kind === 'domain');
172
+ const coveredDomain = domainBranches.filter(b => b.coveredBy && registryRuleIds.includes(b.coveredBy));
173
+ const uncoveredDomain = domainBranches.filter(b => !b.coveredBy || !registryRuleIds.includes(b.coveredBy));
174
+
175
+ // Constraints
176
+ const invariantBranches = manifest.branches.filter(b => b.kind === 'invariant');
177
+ const coveredInvariants = invariantBranches.filter(b => b.coveredBy && registryConstraintIds.includes(b.coveredBy));
178
+ const uncoveredInvariants = invariantBranches.filter(b => !b.coveredBy || !registryConstraintIds.includes(b.coveredBy));
179
+
180
+ // Contracts
181
+ const needContracts = manifest.rulesNeedingContracts;
182
+ const haveContracts = needContracts.filter(id => rulesWithContracts.includes(id));
183
+ const missingContracts = needContracts.filter(id => !rulesWithContracts.includes(id));
184
+
185
+ // Context
186
+ const neededFields = manifest.stateFields.filter(f => f.usedByRule);
187
+ const coveredFields = neededFields.filter(f => f.inContext);
188
+ const missingFields = neededFields.filter(f => !f.inContext);
189
+
190
+ // Events
191
+ const coveredTransitions = manifest.transitions.filter(t => t.eventTag);
192
+ const missingTransitions = manifest.transitions.filter(t => !t.eventTag);
193
+
194
+ // Score
195
+ const ruleScore = domainBranches.length > 0 ? (coveredDomain.length / domainBranches.length) * 40 : 40;
196
+ const constraintScore = invariantBranches.length > 0 ? (coveredInvariants.length / invariantBranches.length) * 20 : 20;
197
+ const contractScore = needContracts.length > 0 ? (haveContracts.length / needContracts.length) * 15 : 15;
198
+ const contextScore = neededFields.length > 0 ? (coveredFields.length / neededFields.length) * 15 : 15;
199
+ const eventScore = manifest.transitions.length > 0 ? (coveredTransitions.length / manifest.transitions.length) * 10 : 10;
200
+
201
+ const score = Math.round(ruleScore + constraintScore + contractScore + contextScore + eventScore);
202
+ const rating = score >= 90 ? 'complete' : score >= 70 ? 'good' : score >= 50 ? 'partial' : 'incomplete';
203
+
204
+ const report: CompletenessReport = {
205
+ score,
206
+ rating,
207
+ rules: { total: domainBranches.length, covered: coveredDomain.length, uncovered: uncoveredDomain },
208
+ constraints: { total: invariantBranches.length, covered: coveredInvariants.length, uncovered: uncoveredInvariants },
209
+ contracts: { total: needContracts.length, withContracts: haveContracts.length, missing: missingContracts },
210
+ context: { total: neededFields.length, covered: coveredFields.length, missing: missingFields },
211
+ events: { total: manifest.transitions.length, covered: coveredTransitions.length, missing: missingTransitions },
212
+ };
213
+
214
+ if (config?.strict && score < threshold) {
215
+ throw new Error(`Praxis completeness ${score}/100 (${rating}) — below threshold ${threshold}. ${uncoveredDomain.length} uncovered rules, ${uncoveredInvariants.length} uncovered invariants, ${missingContracts.length} missing contracts.`);
216
+ }
217
+
218
+ return report;
219
+ }
220
+
221
+ /**
222
+ * Format a completeness report as human-readable text.
223
+ */
224
+ export function formatReport(report: CompletenessReport): string {
225
+ const lines: string[] = [];
226
+ const icon = report.rating === 'complete' ? '✅' : report.rating === 'good' ? '🟢' : report.rating === 'partial' ? '🟡' : '🔴';
227
+
228
+ lines.push(`${icon} Praxis Completeness: ${report.score}/100 (${report.rating})`);
229
+ lines.push('');
230
+ lines.push(`Rules: ${report.rules.covered}/${report.rules.total} domain branches covered (${pct(report.rules.covered, report.rules.total)})`);
231
+ lines.push(`Constraints: ${report.constraints.covered}/${report.constraints.total} invariants covered (${pct(report.constraints.covered, report.constraints.total)})`);
232
+ lines.push(`Contracts: ${report.contracts.withContracts}/${report.contracts.total} rules have contracts (${pct(report.contracts.withContracts, report.contracts.total)})`);
233
+ lines.push(`Context: ${report.context.covered}/${report.context.total} state fields in context (${pct(report.context.covered, report.context.total)})`);
234
+ lines.push(`Events: ${report.events.covered}/${report.events.total} transitions have events (${pct(report.events.covered, report.events.total)})`);
235
+
236
+ if (report.rules.uncovered.length > 0) {
237
+ lines.push('');
238
+ lines.push('Uncovered domain logic:');
239
+ for (const b of report.rules.uncovered) {
240
+ lines.push(` ❌ ${b.location}: ${b.condition}${b.note ? ` — ${b.note}` : ''}`);
241
+ }
242
+ }
243
+
244
+ if (report.constraints.uncovered.length > 0) {
245
+ lines.push('');
246
+ lines.push('Uncovered invariants:');
247
+ for (const b of report.constraints.uncovered) {
248
+ lines.push(` ❌ ${b.location}: ${b.condition}${b.note ? ` — ${b.note}` : ''}`);
249
+ }
250
+ }
251
+
252
+ if (report.contracts.missing.length > 0) {
253
+ lines.push('');
254
+ lines.push('Rules missing contracts:');
255
+ for (const id of report.contracts.missing) {
256
+ lines.push(` 📝 ${id}`);
257
+ }
258
+ }
259
+
260
+ if (report.events.missing.length > 0) {
261
+ lines.push('');
262
+ lines.push('State transitions without events:');
263
+ for (const t of report.events.missing) {
264
+ lines.push(` ⚡ ${t.location}: ${t.description}`);
265
+ }
266
+ }
267
+
268
+ return lines.join('\n');
269
+ }
270
+
271
+ function pct(a: number, b: number): string {
272
+ if (b === 0) return '100%';
273
+ return Math.round((a / b) * 100) + '%';
274
+ }
@@ -15,6 +15,7 @@ import type {
15
15
  } from './protocol.js';
16
16
  import { PRAXIS_PROTOCOL_VERSION } from './protocol.js';
17
17
  import { PraxisRegistry } from './rules.js';
18
+ import { RuleResult } from './rule-result.js';
18
19
 
19
20
  /**
20
21
  * Options for creating a Praxis engine
@@ -28,6 +29,20 @@ export interface PraxisEngineOptions<TContext = unknown> {
28
29
  initialFacts?: PraxisFact[];
29
30
  /** Initial metadata (optional) */
30
31
  initialMeta?: Record<string, unknown>;
32
+ /**
33
+ * Fact deduplication strategy (default: 'last-write-wins').
34
+ *
35
+ * - 'none': facts accumulate without dedup (original behavior)
36
+ * - 'last-write-wins': only keep the latest fact per tag (most common)
37
+ * - 'append': keep all facts but cap at maxFacts
38
+ */
39
+ factDedup?: 'none' | 'last-write-wins' | 'append';
40
+ /**
41
+ * Maximum number of facts to retain (default: 1000).
42
+ * When exceeded, oldest facts are evicted (FIFO).
43
+ * Set to 0 for unlimited (not recommended).
44
+ */
45
+ maxFacts?: number;
31
46
  }
32
47
 
33
48
  /**
@@ -69,9 +84,13 @@ function safeClone<T>(value: T): T {
69
84
  export class LogicEngine<TContext = unknown> {
70
85
  private state: PraxisState & { context: TContext };
71
86
  private readonly registry: PraxisRegistry<TContext>;
87
+ private readonly factDedup: 'none' | 'last-write-wins' | 'append';
88
+ private readonly maxFacts: number;
72
89
 
73
90
  constructor(options: PraxisEngineOptions<TContext>) {
74
91
  this.registry = options.registry;
92
+ this.factDedup = options.factDedup ?? 'last-write-wins';
93
+ this.maxFacts = options.maxFacts ?? 1000;
75
94
  this.state = {
76
95
  context: options.initialContext,
77
96
  facts: options.initialFacts ?? [],
@@ -132,8 +151,16 @@ export class LogicEngine<TContext = unknown> {
132
151
  const diagnostics: PraxisDiagnostics[] = [];
133
152
  let newState = { ...this.state };
134
153
 
154
+ // ── Inject events into state so rules can access them via state.events ──
155
+ const stateWithEvents = {
156
+ ...newState,
157
+ events, // current batch — rules can read state.events
158
+ };
159
+
135
160
  // Apply rules
136
161
  const newFacts: PraxisFact[] = [];
162
+ const retractions: string[] = [];
163
+ const eventTags = new Set(events.map(e => e.tag));
137
164
  for (const ruleId of config.ruleIds) {
138
165
  const rule = this.registry.getRule(ruleId);
139
166
  if (!rule) {
@@ -145,9 +172,45 @@ export class LogicEngine<TContext = unknown> {
145
172
  continue;
146
173
  }
147
174
 
175
+ // Event type filtering: if rule declares eventTypes, skip unless
176
+ // at least one event in the batch matches.
177
+ if (rule.eventTypes) {
178
+ const filterTags = Array.isArray(rule.eventTypes) ? rule.eventTypes : [rule.eventTypes];
179
+ if (!filterTags.some(t => eventTags.has(t))) {
180
+ continue; // No matching events — skip this rule
181
+ }
182
+ }
183
+
148
184
  try {
149
- const ruleFacts = rule.impl(newState, events);
150
- newFacts.push(...ruleFacts);
185
+ const rawResult = rule.impl(stateWithEvents, events);
186
+
187
+ // Support both legacy PraxisFact[] return and new RuleResult return
188
+ if (rawResult instanceof RuleResult) {
189
+ rawResult.ruleId = ruleId;
190
+
191
+ switch (rawResult.kind) {
192
+ case 'emit':
193
+ newFacts.push(...rawResult.facts);
194
+ break;
195
+ case 'retract':
196
+ retractions.push(...rawResult.retractTags);
197
+ break;
198
+ case 'noop':
199
+ case 'skip':
200
+ // Traceable no-ops — store in diagnostics for introspection
201
+ if (rawResult.reason) {
202
+ diagnostics.push({
203
+ kind: 'rule-error', // reused kind — could add 'rule-trace' in protocol v2
204
+ message: `[${rawResult.kind}] ${ruleId}: ${rawResult.reason}`,
205
+ data: { ruleId, resultKind: rawResult.kind, reason: rawResult.reason },
206
+ });
207
+ }
208
+ break;
209
+ }
210
+ } else if (Array.isArray(rawResult)) {
211
+ // Legacy: PraxisFact[] — backward compatible
212
+ newFacts.push(...rawResult);
213
+ }
151
214
  } catch (error) {
152
215
  diagnostics.push({
153
216
  kind: 'rule-error',
@@ -157,10 +220,42 @@ export class LogicEngine<TContext = unknown> {
157
220
  }
158
221
  }
159
222
 
223
+ // ── Apply retractions ──
224
+ let existingFacts = newState.facts;
225
+ if (retractions.length > 0) {
226
+ const retractSet = new Set(retractions);
227
+ existingFacts = existingFacts.filter(f => !retractSet.has(f.tag));
228
+ }
229
+
230
+ // Merge new facts with deduplication
231
+ let mergedFacts: PraxisFact[];
232
+ switch (this.factDedup) {
233
+ case 'last-write-wins': {
234
+ // Build a map keyed by tag — new facts overwrite old ones with same tag
235
+ const factMap = new Map<string, PraxisFact>();
236
+ for (const f of existingFacts) factMap.set(f.tag, f);
237
+ for (const f of newFacts) factMap.set(f.tag, f);
238
+ mergedFacts = Array.from(factMap.values());
239
+ break;
240
+ }
241
+ case 'append':
242
+ mergedFacts = [...existingFacts, ...newFacts];
243
+ break;
244
+ case 'none':
245
+ default:
246
+ mergedFacts = [...existingFacts, ...newFacts];
247
+ break;
248
+ }
249
+
250
+ // Enforce maxFacts limit (evict oldest first)
251
+ if (this.maxFacts > 0 && mergedFacts.length > this.maxFacts) {
252
+ mergedFacts = mergedFacts.slice(mergedFacts.length - this.maxFacts);
253
+ }
254
+
160
255
  // Add new facts to state
161
256
  newState = {
162
257
  ...newState,
163
- facts: [...newState.facts, ...newFacts],
258
+ facts: mergedFacts,
164
259
  };
165
260
 
166
261
  // Check constraints
@@ -221,6 +316,35 @@ export class LogicEngine<TContext = unknown> {
221
316
  };
222
317
  }
223
318
 
319
+ /**
320
+ * Atomically update context AND process events in a single call.
321
+ *
322
+ * This avoids the fragile pattern of calling updateContext() then step()
323
+ * separately, where rules could see stale context if the ordering is wrong.
324
+ *
325
+ * @param updater Function that produces new context from old context
326
+ * @param events Events to process after context is updated
327
+ * @returns Result with new state and diagnostics
328
+ *
329
+ * @example
330
+ * engine.stepWithContext(
331
+ * ctx => ({ ...ctx, sprintName: sprint.name, items: sprint.items }),
332
+ * [{ tag: 'sprint.update', payload: { name: sprint.name } }]
333
+ * );
334
+ */
335
+ stepWithContext(
336
+ updater: (context: TContext) => TContext,
337
+ events: PraxisEvent[]
338
+ ): PraxisStepResult {
339
+ // Update context first — rules see fresh data
340
+ this.state = {
341
+ ...this.state,
342
+ context: updater(this.state.context),
343
+ };
344
+ // Then step with the now-current context
345
+ return this.step(events);
346
+ }
347
+
224
348
  /**
225
349
  * Add facts directly (for exceptional cases).
226
350
  * Generally, facts should be added through rules.
@@ -234,6 +358,22 @@ export class LogicEngine<TContext = unknown> {
234
358
  };
235
359
  }
236
360
 
361
+ /**
362
+ * Check all constraints without processing any events.
363
+ *
364
+ * Useful for validation-only scenarios (e.g., form validation,
365
+ * pre-save checks) where you want constraint diagnostics without
366
+ * triggering any rules.
367
+ *
368
+ * @returns Array of constraint violation diagnostics (empty = all passing)
369
+ */
370
+ checkConstraints(): PraxisDiagnostics[] {
371
+ return this.stepWithConfig([], {
372
+ ruleIds: [],
373
+ constraintIds: this.registry.getConstraintIds(),
374
+ }).diagnostics;
375
+ }
376
+
237
377
  /**
238
378
  * Clear all facts
239
379
  */
@@ -32,3 +32,25 @@ export {
32
32
  // Config Generator - Generate PluresDB config from schemas
33
33
  export type { PluresDBGeneratorOptions, GeneratedPluresDBFile } from './generator.js';
34
34
  export { PluresDBGenerator, createPluresDBGenerator } from './generator.js';
35
+
36
+ // Chronicle - Causal graph tracking for state transitions
37
+ export type {
38
+ TraceDirection,
39
+ EdgeType,
40
+ ChronicleEvent,
41
+ ChronicleNode,
42
+ ChronicleEdge,
43
+ Chronicle,
44
+ ChronosTraceParams,
45
+ ChronosSearchParams,
46
+ McpToolResult,
47
+ ChronosMcpTools,
48
+ } from '../chronicle/index.js';
49
+ export {
50
+ ChronicleContext,
51
+ PluresDbChronicle,
52
+ createChronicle,
53
+ CHRONICLE_PATHS,
54
+ createChronosMcpTools,
55
+ } from '../chronicle/index.js';
56
+ export type { ChronicleSpan } from '../chronicle/index.js';