@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.
- package/README.md +44 -0
- package/dist/browser/chunk-MJK3IYTJ.js +384 -0
- package/dist/browser/{chunk-K377RW4V.js → chunk-N63K4KWS.js} +1 -1
- package/dist/browser/{engine-YJZV4SLD.js → engine-YIEGSX7U.js} +1 -1
- package/dist/browser/index.d.ts +104 -2
- package/dist/browser/index.js +188 -7
- package/dist/browser/integrations/svelte.d.ts +2 -2
- package/dist/browser/integrations/svelte.js +2 -2
- package/dist/browser/{reactive-engine.svelte-9aS0kTa8.d.ts → reactive-engine.svelte-DjynI82A.d.ts} +139 -5
- package/dist/node/{chunk-PRPQO6R5.js → chunk-5JQJZADT.js} +1 -1
- package/dist/node/chunk-KMJWAFZV.js +389 -0
- package/dist/node/{chunk-5RH7UAQC.js → chunk-PTH6MD6P.js} +1 -0
- package/dist/node/cli/index.cjs +1553 -839
- package/dist/node/cli/index.js +39 -2
- package/dist/node/cloud/index.d.cts +1 -1
- package/dist/node/cloud/index.d.ts +1 -1
- package/dist/node/components/index.d.cts +2 -2
- package/dist/node/components/index.d.ts +2 -2
- package/dist/node/conversations-KQBXTP3N.js +596 -0
- package/dist/node/{engine-2DQBKBJC.js → engine-FEN5IYZ5.js} +1 -1
- package/dist/node/index.cjs +911 -43
- package/dist/node/index.d.cts +574 -7
- package/dist/node/index.d.ts +574 -7
- package/dist/node/index.js +672 -26
- package/dist/node/integrations/svelte.cjs +190 -3
- package/dist/node/integrations/svelte.d.cts +3 -3
- package/dist/node/integrations/svelte.d.ts +3 -3
- package/dist/node/integrations/svelte.js +2 -2
- package/dist/node/{protocol-Qek7ebBl.d.ts → protocol-DcyGMmWY.d.cts} +8 -1
- package/dist/node/{protocol-Qek7ebBl.d.cts → protocol-DcyGMmWY.d.ts} +8 -1
- package/dist/node/{reactive-engine.svelte-CRNqHlbv.d.ts → reactive-engine.svelte-Cg0Yc2Hs.d.cts} +145 -6
- package/dist/node/{reactive-engine.svelte-BFIZfawz.d.cts → reactive-engine.svelte-DekxqFu0.d.ts} +145 -6
- package/dist/node/{terminal-adapter-B-UK_Vdz.d.ts → terminal-adapter-CvIvgTo4.d.ts} +1 -1
- package/dist/node/{terminal-adapter-BQSIF5bf.d.cts → terminal-adapter-Db-snPJ3.d.cts} +1 -1
- package/dist/node/{validate-CNHUULQE.js → validate-EN3M4FUR.js} +1 -1
- package/dist/node/{verify-KLJRXVJS.js → verify-7VZRP2WS.js} +2 -2
- package/docs/BOT_UPDATE_POLICY.md +125 -0
- package/docs/DOGFOODING_CHECKLIST.md +254 -0
- package/docs/DOGFOODING_INDEX.md +169 -0
- package/docs/DOGFOODING_QUICK_START.md +140 -0
- package/docs/KNO_ENG_EXTRACTION_PLAN.md +577 -0
- package/docs/PLURES_TOOLS_INVENTORY.md +170 -0
- package/docs/README.md +12 -0
- package/docs/TESTING_BOT_WORKFLOWS.md +154 -0
- package/docs/conversations/INTEGRATION_POINTS.md +719 -0
- package/docs/conversations/README.md +168 -0
- package/docs/core/extending-praxis-core.md +604 -0
- package/docs/core/praxis-core-api.md +385 -0
- package/docs/decision-ledger/contract-index.json +2 -2
- package/docs/decision-ledger/decisions/2026-02-01-monorepo-organization.md +130 -0
- package/docs/examples/DOGFOODING_WORKFLOW_EXAMPLE.md +295 -0
- package/docs/examples/README.md +41 -0
- package/docs/workflows/pr-overlap-guard.md +50 -0
- package/package.json +8 -3
- package/src/__tests__/chronicle.test.ts +512 -0
- package/src/__tests__/conversations.test.ts +312 -0
- package/src/__tests__/edge-cases.test.ts +1 -1
- package/src/__tests__/engine-dx.test.ts +355 -0
- package/src/__tests__/engine-v2.test.ts +532 -0
- package/src/cli/commands/conversations.ts +252 -0
- package/src/cli/index.ts +73 -0
- package/src/conversations/README.md +230 -0
- package/src/conversations/candidate.schema.json +123 -0
- package/src/conversations/candidates.ts +114 -0
- package/src/conversations/capture.ts +56 -0
- package/src/conversations/classify.ts +110 -0
- package/src/conversations/conversation.schema.json +106 -0
- package/src/conversations/emitters/fs.ts +65 -0
- package/src/conversations/emitters/github.ts +115 -0
- package/src/conversations/gate.ts +102 -0
- package/src/conversations/index.ts +28 -0
- package/src/conversations/normalize.ts +51 -0
- package/src/conversations/redact.ts +57 -0
- package/src/conversations/types.ts +96 -0
- package/src/core/chronicle/chronicle.ts +227 -0
- package/src/core/chronicle/context.ts +80 -0
- package/src/core/chronicle/index.ts +53 -0
- package/src/core/chronicle/mcp.ts +135 -0
- package/src/core/chronicle/types.ts +61 -0
- package/src/core/completeness.ts +274 -0
- package/src/core/engine.ts +143 -3
- package/src/core/pluresdb/index.ts +22 -0
- package/src/core/pluresdb/store.ts +171 -8
- package/src/core/protocol.ts +7 -0
- package/src/core/rule-result.ts +130 -0
- package/src/core/rules.ts +24 -5
- package/src/core/ui-rules.ts +340 -0
- package/src/dsl/index.ts +6 -0
- package/src/index.ts +45 -0
- package/src/integrations/pluresdb.ts +22 -0
- package/src/vite/completeness-plugin.ts +72 -0
- package/dist/browser/chunk-VOMLVI6V.js +0 -197
- 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
|
+
}
|
package/src/core/engine.ts
CHANGED
|
@@ -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
|
|
150
|
-
|
|
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:
|
|
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';
|