@plures/praxis 1.3.0 → 1.4.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 (36) hide show
  1. package/dist/node/chunk-2IUFZBH3.js +87 -0
  2. package/dist/node/{chunk-WZ6B3LZ6.js → chunk-7CSWBDFL.js} +3 -56
  3. package/dist/node/{chunk-5JQJZADT.js → chunk-7M3HV4XR.js} +3 -3
  4. package/dist/node/{chunk-PTH6MD6P.js → chunk-FWOXU4MM.js} +1 -1
  5. package/dist/node/chunk-PGVSB6NR.js +59 -0
  6. package/dist/node/cli/index.cjs +1078 -211
  7. package/dist/node/cli/index.js +21 -2
  8. package/dist/node/index.cjs +1111 -0
  9. package/dist/node/index.d.cts +499 -1
  10. package/dist/node/index.d.ts +499 -1
  11. package/dist/node/index.js +1092 -78
  12. package/dist/node/integrations/svelte.js +2 -2
  13. package/dist/node/{reverse-W7THPV45.js → reverse-YD3CWIGM.js} +3 -2
  14. package/dist/node/rules-4DAJ4Z4N.js +7 -0
  15. package/dist/node/server-SYZPDULV.js +361 -0
  16. package/dist/node/{validate-EN3M4FUR.js → validate-TQGVIG7G.js} +4 -3
  17. package/package.json +28 -2
  18. package/src/__tests__/expectations.test.ts +364 -0
  19. package/src/__tests__/factory.test.ts +426 -0
  20. package/src/__tests__/mcp-server.test.ts +310 -0
  21. package/src/__tests__/project.test.ts +396 -0
  22. package/src/cli/index.ts +28 -0
  23. package/src/expectations/expectations.ts +471 -0
  24. package/src/expectations/index.ts +29 -0
  25. package/src/expectations/types.ts +95 -0
  26. package/src/factory/factory.ts +634 -0
  27. package/src/factory/index.ts +27 -0
  28. package/src/factory/types.ts +64 -0
  29. package/src/index.ts +57 -0
  30. package/src/mcp/index.ts +33 -0
  31. package/src/mcp/server.ts +485 -0
  32. package/src/mcp/types.ts +161 -0
  33. package/src/project/index.ts +31 -0
  34. package/src/project/project.ts +423 -0
  35. package/src/project/types.ts +87 -0
  36. /package/dist/node/{chunk-R2PSBPKQ.js → chunk-TEMFJOIH.js} +0 -0
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Praxis Rules Factory — Types
3
+ *
4
+ * Configuration types for predefined rule module factories.
5
+ */
6
+
7
+ // ─── Input Rules ────────────────────────────────────────────────────────────
8
+
9
+ export type SanitizationType = 'sql-injection' | 'xss' | 'path-traversal' | 'command-injection';
10
+
11
+ export interface InputRulesConfig {
12
+ /** Sanitization checks to apply */
13
+ sanitize?: SanitizationType[];
14
+ /** Maximum input length (0 = unlimited) */
15
+ maxLength?: number;
16
+ /** Whether the input is required (non-empty) */
17
+ required?: boolean;
18
+ /** Custom field name for facts/events (default: 'input') */
19
+ fieldName?: string;
20
+ }
21
+
22
+ // ─── Toast Rules ────────────────────────────────────────────────────────────
23
+
24
+ export interface ToastRulesConfig {
25
+ /** Only show toast if there's a meaningful diff */
26
+ requireDiff?: boolean;
27
+ /** Auto-dismiss after N milliseconds (0 = no auto-dismiss) */
28
+ autoDismissMs?: number;
29
+ /** Prevent duplicate toasts with same message */
30
+ deduplicate?: boolean;
31
+ }
32
+
33
+ // ─── Form Rules ─────────────────────────────────────────────────────────────
34
+
35
+ export interface FormRulesConfig {
36
+ /** Validate fields on blur */
37
+ validateOnBlur?: boolean;
38
+ /** Gate form submission on validation passing */
39
+ submitGate?: boolean;
40
+ /** Custom form name for namespacing facts */
41
+ formName?: string;
42
+ }
43
+
44
+ // ─── Navigation Rules ───────────────────────────────────────────────────────
45
+
46
+ export interface NavigationRulesConfig {
47
+ /** Warn/block navigation when form has unsaved changes */
48
+ dirtyGuard?: boolean;
49
+ /** Require authentication for navigation */
50
+ authRequired?: boolean;
51
+ }
52
+
53
+ // ─── Data Rules ─────────────────────────────────────────────────────────────
54
+
55
+ export interface DataRulesConfig {
56
+ /** Enable optimistic UI updates */
57
+ optimisticUpdate?: boolean;
58
+ /** Rollback optimistic updates on error */
59
+ rollbackOnError?: boolean;
60
+ /** Invalidate relevant caches on data change */
61
+ cacheInvalidation?: boolean;
62
+ /** Custom entity name for facts */
63
+ entityName?: string;
64
+ }
package/src/index.ts CHANGED
@@ -353,3 +353,60 @@ export type { UIContext } from './core/ui-rules.js';
353
353
  export { auditCompleteness, formatReport } from './core/completeness.js';
354
354
  export type { LogicBranch, StateField, StateTransition, CompletenessReport, CompletenessConfig } from './core/completeness.js';
355
355
 
356
+ // ── Expectations DSL (behavioral declarations) ─────────────────────────────
357
+ export {
358
+ Expectation,
359
+ ExpectationSet,
360
+ expectBehavior,
361
+ verify,
362
+ formatVerificationReport,
363
+ } from './expectations/index.js';
364
+ export type {
365
+ ExpectationCondition,
366
+ ConditionStatus,
367
+ ConditionResult,
368
+ ExpectationResult,
369
+ VerificationReport,
370
+ ExpectationSetOptions,
371
+ VerifiableRegistry,
372
+ VerifiableDescriptor,
373
+ } from './expectations/index.js';
374
+
375
+ // ── Rules Factory (predefined modules) ─────────────────────────────────────
376
+ export {
377
+ inputRules,
378
+ toastRules,
379
+ formRules,
380
+ navigationRules,
381
+ dataRules,
382
+ } from './factory/index.js';
383
+ export type {
384
+ InputRulesConfig,
385
+ ToastRulesConfig,
386
+ FormRulesConfig,
387
+ NavigationRulesConfig,
388
+ DataRulesConfig,
389
+ SanitizationType,
390
+ } from './factory/index.js';
391
+
392
+ // ── Project Logic (developer workflow) ──────────────────────────────────────
393
+ export {
394
+ defineGate,
395
+ semverContract,
396
+ commitFromState,
397
+ branchRules,
398
+ lintGate,
399
+ formatGate,
400
+ expectationGate,
401
+ } from './project/index.js';
402
+ export type {
403
+ GateConfig,
404
+ GateState,
405
+ GateStatus,
406
+ SemverContractConfig,
407
+ SemverReport,
408
+ PraxisDiff,
409
+ BranchRulesConfig,
410
+ PredefinedGateConfig,
411
+ } from './project/index.js';
412
+
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Praxis MCP Module
3
+ *
4
+ * Public API for the MCP (Model Context Protocol) server.
5
+ * Exposes Praxis engine operations as AI-consumable tools.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { createPraxisMcpServer } from '@plures/praxis/mcp';
10
+ * ```
11
+ */
12
+
13
+ export { createPraxisMcpServer } from './server.js';
14
+ export type {
15
+ PraxisMcpServerOptions,
16
+ InspectInput,
17
+ InspectOutput,
18
+ RuleInfo,
19
+ ConstraintInfo,
20
+ EvaluateInput,
21
+ EvaluateOutput,
22
+ AuditInput,
23
+ AuditOutput,
24
+ SuggestInput,
25
+ SuggestOutput,
26
+ StepInput,
27
+ StepOutput,
28
+ FactsOutput,
29
+ ContractsInput,
30
+ ContractsOutput,
31
+ ContractInfo,
32
+ GatesInput,
33
+ } from './types.js';
@@ -0,0 +1,485 @@
1
+ /**
2
+ * Praxis MCP Server
3
+ *
4
+ * Exposes Praxis engine operations as MCP tools for AI assistants.
5
+ * Supports both stdio transport (CLI usage) and library import.
6
+ *
7
+ * Tools:
8
+ * - praxis.inspect — list all registered rules, constraints, contracts
9
+ * - praxis.evaluate — run a rule against given events
10
+ * - praxis.audit — run completeness audit against a manifest
11
+ * - praxis.suggest — suggest rules/constraints for a gap
12
+ * - praxis.facts — get current fact state
13
+ * - praxis.step — step the engine with events
14
+ * - praxis.contracts — list all contracts with coverage status
15
+ */
16
+
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { z } from 'zod';
20
+ import { LogicEngine } from '../core/engine.js';
21
+ import { RuleResult } from '../core/rule-result.js';
22
+ import { auditCompleteness, formatReport } from '../core/completeness.js';
23
+ import { getContractFromDescriptor } from '../decision-ledger/types.js';
24
+ import type {
25
+ PraxisMcpServerOptions,
26
+ InspectOutput,
27
+ EvaluateOutput,
28
+ StepOutput,
29
+ FactsOutput,
30
+ ContractsOutput,
31
+ AuditOutput,
32
+ SuggestOutput,
33
+ RuleInfo,
34
+ ConstraintInfo,
35
+ } from './types.js';
36
+
37
+ /**
38
+ * Create a Praxis MCP server with all tools registered.
39
+ *
40
+ * @example
41
+ * ```ts
42
+ * import { createPraxisMcpServer } from '@plures/praxis/mcp';
43
+ * import { PraxisRegistry } from '@plures/praxis';
44
+ *
45
+ * const registry = new PraxisRegistry();
46
+ * // ... register rules ...
47
+ *
48
+ * const server = createPraxisMcpServer({
49
+ * initialContext: {},
50
+ * registry,
51
+ * });
52
+ *
53
+ * // Start via stdio for CLI usage
54
+ * await server.start();
55
+ *
56
+ * // Or use the McpServer instance directly
57
+ * const mcpServer = server.mcpServer;
58
+ * ```
59
+ */
60
+ export function createPraxisMcpServer<TContext = unknown>(
61
+ options: PraxisMcpServerOptions<TContext>,
62
+ ) {
63
+ const {
64
+ name = '@plures/praxis',
65
+ version = '1.0.0',
66
+ initialContext,
67
+ registry,
68
+ initialFacts,
69
+ } = options;
70
+
71
+ // Create the engine that backs all operations
72
+ const engine = new LogicEngine<TContext>({
73
+ initialContext,
74
+ registry,
75
+ initialFacts,
76
+ });
77
+
78
+ // Create MCP server
79
+ const server = new McpServer({
80
+ name,
81
+ version,
82
+ });
83
+
84
+ // ── praxis.inspect ──────────────────────────────────────────────────────
85
+
86
+ server.tool(
87
+ 'praxis.inspect',
88
+ 'List all registered rules, constraints, and their contracts',
89
+ {
90
+ filter: z.string().optional().describe('Filter rule/constraint IDs by pattern (substring match)'),
91
+ includeContracts: z.boolean().optional().describe('Include full contract details (default: false)'),
92
+ },
93
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
94
+ const filter = params.filter;
95
+ const includeContracts = params.includeContracts ?? false;
96
+
97
+ let rules = registry.getAllRules();
98
+ let constraints = registry.getAllConstraints();
99
+
100
+ if (filter) {
101
+ rules = rules.filter(r => r.id.includes(filter));
102
+ constraints = constraints.filter(c => c.id.includes(filter));
103
+ }
104
+
105
+ const ruleInfos: RuleInfo[] = rules.map(r => {
106
+ const contract = getContractFromDescriptor(r);
107
+ return {
108
+ id: r.id,
109
+ description: r.description,
110
+ eventTypes: r.eventTypes,
111
+ hasContract: !!contract,
112
+ contract: includeContracts ? contract : undefined,
113
+ meta: r.meta,
114
+ };
115
+ });
116
+
117
+ const constraintInfos: ConstraintInfo[] = constraints.map(c => {
118
+ const contract = getContractFromDescriptor(c);
119
+ return {
120
+ id: c.id,
121
+ description: c.description,
122
+ hasContract: !!contract,
123
+ contract: includeContracts ? contract : undefined,
124
+ meta: c.meta,
125
+ };
126
+ });
127
+
128
+ const output: InspectOutput = {
129
+ rules: ruleInfos,
130
+ constraints: constraintInfos,
131
+ summary: {
132
+ totalRules: ruleInfos.length,
133
+ totalConstraints: constraintInfos.length,
134
+ rulesWithContracts: ruleInfos.filter(r => r.hasContract).length,
135
+ constraintsWithContracts: constraintInfos.filter(c => c.hasContract).length,
136
+ },
137
+ };
138
+
139
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
140
+ },
141
+ );
142
+
143
+ // ── praxis.evaluate ─────────────────────────────────────────────────────
144
+
145
+ server.tool(
146
+ 'praxis.evaluate',
147
+ 'Run a specific rule against given events and return the result',
148
+ {
149
+ ruleId: z.string().describe('The rule ID to evaluate'),
150
+ events: z.array(z.object({
151
+ tag: z.string(),
152
+ payload: z.unknown(),
153
+ })).describe('Events to process through the rule'),
154
+ },
155
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
156
+ const rule = registry.getRule(params.ruleId);
157
+ if (!rule) {
158
+ return {
159
+ content: [{ type: 'text', text: JSON.stringify({ error: `Rule "${params.ruleId}" not found` }) }],
160
+ };
161
+ }
162
+
163
+ const state = engine.getState();
164
+ const stateWithEvents = { ...state, events: params.events };
165
+
166
+ try {
167
+ const rawResult = rule.impl(stateWithEvents as Parameters<typeof rule.impl>[0], params.events);
168
+
169
+ let output: EvaluateOutput;
170
+ if (rawResult instanceof RuleResult) {
171
+ output = {
172
+ ruleId: params.ruleId,
173
+ resultKind: rawResult.kind,
174
+ facts: rawResult.facts,
175
+ retractedTags: rawResult.retractTags,
176
+ reason: rawResult.reason,
177
+ diagnostics: [],
178
+ };
179
+ } else if (Array.isArray(rawResult)) {
180
+ output = {
181
+ ruleId: params.ruleId,
182
+ resultKind: 'emit',
183
+ facts: rawResult,
184
+ retractedTags: [],
185
+ diagnostics: [],
186
+ };
187
+ } else {
188
+ output = {
189
+ ruleId: params.ruleId,
190
+ resultKind: 'noop',
191
+ facts: [],
192
+ retractedTags: [],
193
+ diagnostics: [],
194
+ };
195
+ }
196
+
197
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
198
+ } catch (error) {
199
+ return {
200
+ content: [{
201
+ type: 'text',
202
+ text: JSON.stringify({
203
+ error: `Rule evaluation failed: ${error instanceof Error ? error.message : String(error)}`,
204
+ ruleId: params.ruleId,
205
+ }),
206
+ }],
207
+ };
208
+ }
209
+ },
210
+ );
211
+
212
+ // ── praxis.audit ────────────────────────────────────────────────────────
213
+
214
+ server.tool(
215
+ 'praxis.audit',
216
+ 'Run completeness audit against a manifest and return the report',
217
+ {
218
+ branches: z.array(z.object({
219
+ location: z.string(),
220
+ condition: z.string(),
221
+ kind: z.enum(['domain', 'invariant', 'ui', 'transport', 'wiring', 'transform']),
222
+ coveredBy: z.string().nullable(),
223
+ note: z.string().optional(),
224
+ })).describe('Logic branches to audit'),
225
+ stateFields: z.array(z.object({
226
+ source: z.string(),
227
+ field: z.string(),
228
+ inContext: z.boolean(),
229
+ usedByRule: z.boolean(),
230
+ })).describe('State fields to check context coverage'),
231
+ transitions: z.array(z.object({
232
+ description: z.string(),
233
+ eventTag: z.string().nullable(),
234
+ location: z.string(),
235
+ })).describe('State transitions to check event coverage'),
236
+ rulesNeedingContracts: z.array(z.string()).describe('Rule IDs that should have contracts'),
237
+ threshold: z.number().optional().describe('Minimum passing score (default: 90)'),
238
+ },
239
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
240
+ const rulesWithContracts = registry.getAllRules()
241
+ .filter(r => getContractFromDescriptor(r))
242
+ .map(r => r.id);
243
+
244
+ const report = auditCompleteness(
245
+ {
246
+ branches: params.branches,
247
+ stateFields: params.stateFields,
248
+ transitions: params.transitions,
249
+ rulesNeedingContracts: params.rulesNeedingContracts,
250
+ },
251
+ registry.getRuleIds(),
252
+ registry.getConstraintIds(),
253
+ rulesWithContracts,
254
+ { threshold: params.threshold },
255
+ );
256
+
257
+ const output: AuditOutput = {
258
+ report,
259
+ formatted: formatReport(report),
260
+ };
261
+
262
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
263
+ },
264
+ );
265
+
266
+ // ── praxis.suggest ──────────────────────────────────────────────────────
267
+
268
+ server.tool(
269
+ 'praxis.suggest',
270
+ 'Given a gap or description, suggest rules/constraints to add',
271
+ {
272
+ gap: z.string().describe('Description of the gap or failing expectation'),
273
+ context: z.record(z.string(), z.unknown()).optional().describe('Current context for suggestions'),
274
+ },
275
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
276
+ const existingRules = registry.getAllRules();
277
+ const _constraints = registry.getAllConstraints();
278
+ void _constraints; // used for future constraint-aware suggestions
279
+ const suggestions: SuggestOutput['suggestions'] = [];
280
+
281
+ // Analyze gap description against existing rules
282
+ const gapLower = params.gap.toLowerCase();
283
+
284
+ // Check if any existing rule partially covers this
285
+ const relatedRules = existingRules.filter(r =>
286
+ r.description.toLowerCase().includes(gapLower) ||
287
+ gapLower.includes(r.id.toLowerCase()),
288
+ );
289
+
290
+ if (relatedRules.length > 0) {
291
+ // Suggest adding a constraint to complement existing rules
292
+ for (const rule of relatedRules) {
293
+ suggestions.push({
294
+ type: 'constraint',
295
+ id: `${rule.id}/guard`,
296
+ description: `Add a constraint to guard the behavior described by rule "${rule.id}"`,
297
+ rationale: `Rule "${rule.id}" (${rule.description}) exists but may not fully cover: ${params.gap}`,
298
+ });
299
+ }
300
+ }
301
+
302
+ // If gap mentions validation/invariant-like terms, suggest constraint
303
+ const invariantTerms = ['must', 'never', 'always', 'require', 'valid', 'invalid', 'prevent'];
304
+ if (invariantTerms.some(t => gapLower.includes(t))) {
305
+ suggestions.push({
306
+ type: 'constraint',
307
+ id: suggestId(params.gap, 'constraint'),
308
+ description: `Constraint: ${params.gap}`,
309
+ rationale: 'Gap description contains invariant language — a constraint would encode this guarantee',
310
+ });
311
+ }
312
+
313
+ // If gap mentions behavior/action terms, suggest rule
314
+ const ruleTerms = ['when', 'if', 'show', 'emit', 'trigger', 'display', 'update'];
315
+ if (ruleTerms.some(t => gapLower.includes(t))) {
316
+ suggestions.push({
317
+ type: 'rule',
318
+ id: suggestId(params.gap, 'rule'),
319
+ description: `Rule: ${params.gap}`,
320
+ rationale: 'Gap description contains conditional behavior — a rule would implement this logic',
321
+ });
322
+ }
323
+
324
+ // Check if relevant rules lack contracts
325
+ const contractGaps = registry.getContractGaps();
326
+ if (contractGaps.length > 0) {
327
+ const relatedGaps = contractGaps.filter(g =>
328
+ gapLower.includes(g.ruleId.toLowerCase()),
329
+ );
330
+ for (const g of relatedGaps) {
331
+ suggestions.push({
332
+ type: 'contract',
333
+ id: g.ruleId,
334
+ description: `Add contract for "${g.ruleId}" — missing: ${g.missing.join(', ')}`,
335
+ rationale: `Related rule "${g.ruleId}" lacks a contract, which could prevent this gap`,
336
+ });
337
+ }
338
+ }
339
+
340
+ // Suggest an event if the gap describes a state transition
341
+ const eventTerms = ['transition', 'change', 'happen', 'occur', 'fire', 'dispatch'];
342
+ if (eventTerms.some(t => gapLower.includes(t))) {
343
+ suggestions.push({
344
+ type: 'event',
345
+ id: suggestId(params.gap, 'event'),
346
+ description: `Event for: ${params.gap}`,
347
+ rationale: 'Gap description suggests a state transition — an event would make it observable',
348
+ });
349
+ }
350
+
351
+ // Fallback: always suggest at least a rule
352
+ if (suggestions.length === 0) {
353
+ suggestions.push({
354
+ type: 'rule',
355
+ id: suggestId(params.gap, 'rule'),
356
+ description: `Rule: ${params.gap}`,
357
+ rationale: 'No existing rules or constraints cover this gap — a new rule is recommended',
358
+ });
359
+ }
360
+
361
+ const output: SuggestOutput = { suggestions };
362
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
363
+ },
364
+ );
365
+
366
+ // ── praxis.facts ────────────────────────────────────────────────────────
367
+
368
+ server.tool(
369
+ 'praxis.facts',
370
+ 'Get the current fact state of the engine',
371
+ {},
372
+ async (): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
373
+ const facts = engine.getFacts();
374
+ const output: FactsOutput = {
375
+ facts,
376
+ count: facts.length,
377
+ };
378
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
379
+ },
380
+ );
381
+
382
+ // ── praxis.step ─────────────────────────────────────────────────────────
383
+
384
+ server.tool(
385
+ 'praxis.step',
386
+ 'Step the engine with events and return the new state',
387
+ {
388
+ events: z.array(z.object({
389
+ tag: z.string(),
390
+ payload: z.unknown(),
391
+ })).describe('Events to process'),
392
+ },
393
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
394
+ const result = engine.step(params.events);
395
+ const output: StepOutput = {
396
+ facts: result.state.facts,
397
+ diagnostics: result.diagnostics,
398
+ factCount: result.state.facts.length,
399
+ };
400
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
401
+ },
402
+ );
403
+
404
+ // ── praxis.contracts ────────────────────────────────────────────────────
405
+
406
+ server.tool(
407
+ 'praxis.contracts',
408
+ 'List all contracts with their coverage status',
409
+ {
410
+ filter: z.string().optional().describe('Filter by rule/constraint ID (substring match)'),
411
+ },
412
+ async (params): Promise<{ content: Array<{ type: 'text'; text: string }> }> => {
413
+ const rules = registry.getAllRules();
414
+ const constraints = registry.getAllConstraints();
415
+
416
+ type LocalContractInfo = {
417
+ ruleId: string;
418
+ hasContract: boolean;
419
+ contract?: import('../decision-ledger/types.js').Contract;
420
+ type: 'rule' | 'constraint';
421
+ };
422
+
423
+ let contracts: LocalContractInfo[] = [
424
+ ...rules.map(r => ({
425
+ ruleId: r.id,
426
+ hasContract: !!getContractFromDescriptor(r),
427
+ contract: getContractFromDescriptor(r),
428
+ type: 'rule' as const,
429
+ })),
430
+ ...constraints.map(c => ({
431
+ ruleId: c.id,
432
+ hasContract: !!getContractFromDescriptor(c),
433
+ contract: getContractFromDescriptor(c),
434
+ type: 'constraint' as const,
435
+ })),
436
+ ];
437
+
438
+ if (params.filter) {
439
+ contracts = contracts.filter(c => c.ruleId.includes(params.filter!));
440
+ }
441
+
442
+ const total = contracts.length;
443
+ const withContracts = contracts.filter(c => c.hasContract).length;
444
+
445
+ const output: ContractsOutput = {
446
+ contracts,
447
+ coverage: {
448
+ total,
449
+ withContracts,
450
+ percentage: total > 0 ? Math.round((withContracts / total) * 100) : 100,
451
+ },
452
+ };
453
+
454
+ return { content: [{ type: 'text', text: JSON.stringify(output, null, 2) }] };
455
+ },
456
+ );
457
+
458
+ // ── Public API ──────────────────────────────────────────────────────────
459
+
460
+ return {
461
+ /** The underlying MCP server instance */
462
+ mcpServer: server,
463
+ /** The underlying Praxis engine */
464
+ engine,
465
+ /** Start the server on stdio transport */
466
+ async start(): Promise<void> {
467
+ const transport = new StdioServerTransport();
468
+ await server.connect(transport);
469
+ },
470
+ };
471
+ }
472
+
473
+ /**
474
+ * Generate a suggested ID from a gap description and type.
475
+ */
476
+ function suggestId(description: string, type: string): string {
477
+ const slug = description
478
+ .toLowerCase()
479
+ .replace(/[^a-z0-9\s]/g, '')
480
+ .trim()
481
+ .split(/\s+/)
482
+ .slice(0, 3)
483
+ .join('-');
484
+ return `suggested/${type}/${slug}`;
485
+ }