@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
|
@@ -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
|
-
//
|
|
211
|
-
|
|
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
|
-
|
|
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
|
|
377
|
-
|
|
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
|
}
|
package/src/core/protocol.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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 */
|