@contractspec/lib.evolution 3.7.6 → 3.7.10

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.
@@ -1,764 +1,761 @@
1
- // src/analyzer/spec-analyzer.ts
2
- import { randomUUID } from "node:crypto";
3
- import { LifecycleStage } from "@contractspec/lib.lifecycle";
4
- var DEFAULT_OPTIONS = {
5
- minSampleSize: 50,
6
- errorRateThreshold: 0.05,
7
- latencyP99ThresholdMs: 750
8
- };
9
-
10
- class SpecAnalyzer {
11
- logger;
12
- minSampleSize;
13
- errorRateThreshold;
14
- latencyP99ThresholdMs;
15
- throughputDropThreshold;
16
- constructor(options = {}) {
17
- this.logger = options.logger;
18
- this.minSampleSize = options.minSampleSize ?? DEFAULT_OPTIONS.minSampleSize;
19
- this.errorRateThreshold = options.errorRateThreshold ?? DEFAULT_OPTIONS.errorRateThreshold;
20
- this.latencyP99ThresholdMs = options.latencyP99ThresholdMs ?? DEFAULT_OPTIONS.latencyP99ThresholdMs;
21
- this.throughputDropThreshold = options.throughputDropThreshold ?? 0.2;
1
+ // src/analyzer/posthog-telemetry-reader.ts
2
+ class PosthogTelemetryReader {
3
+ reader;
4
+ eventPrefix;
5
+ constructor(reader, options = {}) {
6
+ this.reader = reader;
7
+ this.eventPrefix = options.eventPrefix ?? "observability";
22
8
  }
23
- analyzeSpecUsage(samples) {
24
- if (!samples.length) {
25
- this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skip", {
26
- reason: "no-samples"
27
- });
28
- return [];
29
- }
30
- const groups = new Map;
31
- for (const sample of samples) {
32
- const key = this.operationKey(sample);
33
- const arr = groups.get(key) ?? [];
34
- arr.push(sample);
35
- groups.set(key, arr);
36
- }
37
- const entries = [...groups.values()];
38
- const usable = entries.filter((samplesForOp) => {
39
- const valid = samplesForOp.length >= this.minSampleSize;
40
- if (!valid) {
41
- this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skipOperation", {
42
- operation: this.operationKey(samplesForOp[0]),
43
- sampleSize: samplesForOp.length,
44
- minSampleSize: this.minSampleSize
45
- });
46
- }
47
- return valid;
9
+ async readOperationSamples(input) {
10
+ const result = await this.queryHogQL({
11
+ query: [
12
+ "select",
13
+ " properties.operation as operationKey,",
14
+ " properties.version as version,",
15
+ " properties.durationMs as durationMs,",
16
+ " properties.success as success,",
17
+ " properties.errorCode as errorCode,",
18
+ " properties.tenantId as tenantId,",
19
+ " properties.traceId as traceId,",
20
+ " properties.metadata as metadata,",
21
+ " timestamp as timestamp",
22
+ "from events",
23
+ `where ${buildOperationWhereClause(this.eventPrefix, input)}`,
24
+ "order by timestamp desc",
25
+ `limit ${input.limit ?? 1000}`
26
+ ].join(`
27
+ `),
28
+ values: buildOperationValues(input)
48
29
  });
49
- return usable.map((operationSamples) => this.buildUsageStats(operationSamples));
30
+ return mapOperationSamples(result);
50
31
  }
51
- detectAnomalies(stats, baseline) {
52
- const anomalies = [];
53
- if (!stats.length) {
54
- this.logger?.debug("SpecAnalyzer.detectAnomalies.skip", {
55
- reason: "no-stats"
56
- });
57
- return anomalies;
58
- }
59
- const baselineByOp = new Map((baseline ?? []).map((item) => [this.operationKey(item.operation), item]));
60
- for (const stat of stats) {
61
- const evidence = [];
62
- if (stat.errorRate >= this.errorRateThreshold) {
63
- evidence.push({
64
- type: "telemetry",
65
- description: `Error rate ${stat.errorRate.toFixed(2)} exceeded threshold ${this.errorRateThreshold}`,
66
- data: { errorRate: stat.errorRate }
67
- });
68
- anomalies.push({
69
- operation: stat.operation,
70
- severity: this.toSeverity(stat.errorRate / this.errorRateThreshold),
71
- metric: "error-rate",
72
- description: "Error rate spike",
73
- detectedAt: new Date,
74
- threshold: this.errorRateThreshold,
75
- observedValue: stat.errorRate,
76
- evidence
77
- });
78
- continue;
79
- }
80
- if (stat.p99LatencyMs >= this.latencyP99ThresholdMs) {
81
- evidence.push({
82
- type: "telemetry",
83
- description: `P99 latency ${stat.p99LatencyMs}ms exceeded threshold ${this.latencyP99ThresholdMs}ms`,
84
- data: { p99LatencyMs: stat.p99LatencyMs }
85
- });
86
- anomalies.push({
87
- operation: stat.operation,
88
- severity: this.toSeverity(stat.p99LatencyMs / this.latencyP99ThresholdMs),
89
- metric: "latency",
90
- description: "Latency regression detected",
91
- detectedAt: new Date,
92
- threshold: this.latencyP99ThresholdMs,
93
- observedValue: stat.p99LatencyMs,
94
- evidence
95
- });
96
- continue;
97
- }
98
- const baselineStat = baselineByOp.get(this.operationKey(stat.operation));
99
- if (baselineStat) {
100
- const drop = (baselineStat.totalCalls - stat.totalCalls) / baselineStat.totalCalls;
101
- if (drop >= this.throughputDropThreshold) {
102
- evidence.push({
103
- type: "telemetry",
104
- description: `Throughput dropped by ${(drop * 100).toFixed(1)}% compared to baseline`,
105
- data: {
106
- baselineCalls: baselineStat.totalCalls,
107
- currentCalls: stat.totalCalls
108
- }
109
- });
110
- anomalies.push({
111
- operation: stat.operation,
112
- severity: this.toSeverity(drop / this.throughputDropThreshold),
113
- metric: "throughput",
114
- description: "Usage drop detected",
115
- detectedAt: new Date,
116
- threshold: this.throughputDropThreshold,
117
- observedValue: drop,
118
- evidence
119
- });
120
- }
121
- }
122
- }
123
- return anomalies;
32
+ async readAnomalyBaseline(operation, windowDays = 7) {
33
+ const dateRange = buildWindowRange(windowDays);
34
+ const baseResult = await this.queryHogQL({
35
+ query: [
36
+ "select",
37
+ " count() as totalCalls,",
38
+ " avg(properties.durationMs) as averageLatencyMs,",
39
+ " quantile(0.95)(properties.durationMs) as p95LatencyMs,",
40
+ " quantile(0.99)(properties.durationMs) as p99LatencyMs,",
41
+ " max(properties.durationMs) as maxLatencyMs,",
42
+ " sum(if(properties.success = 1, 1, 0)) as successCount,",
43
+ " sum(if(properties.success = 0, 1, 0)) as errorCount",
44
+ "from events",
45
+ `where ${buildOperationWhereClause(this.eventPrefix, {
46
+ operations: [operation],
47
+ dateRange
48
+ })}`
49
+ ].join(`
50
+ `),
51
+ values: buildOperationValues({
52
+ operations: [operation],
53
+ dateRange
54
+ })
55
+ });
56
+ const stats = mapBaselineStats(baseResult, operation, dateRange);
57
+ if (!stats)
58
+ return null;
59
+ const topErrors = await this.readTopErrors(operation, dateRange);
60
+ return {
61
+ ...stats,
62
+ topErrors
63
+ };
124
64
  }
125
- toIntentPatterns(anomalies, stats) {
126
- const statsByOp = new Map(stats.map((item) => [this.operationKey(item.operation), item]));
127
- return anomalies.map((anomaly) => {
128
- const stat = statsByOp.get(this.operationKey(anomaly.operation));
129
- const confidence = {
130
- score: Math.min(1, (anomaly.observedValue ?? 0) / (anomaly.threshold ?? 1)),
131
- sampleSize: stat?.totalCalls ?? 0,
132
- pValue: undefined
133
- };
134
- return {
135
- id: randomUUID(),
136
- type: this.mapMetricToIntent(anomaly.metric),
137
- description: anomaly.description,
138
- operation: anomaly.operation,
139
- confidence,
140
- metadata: {
141
- observedValue: anomaly.observedValue,
142
- threshold: anomaly.threshold
143
- },
144
- evidence: anomaly.evidence
145
- };
65
+ async readTopErrors(operation, dateRange) {
66
+ const result = await this.queryHogQL({
67
+ query: [
68
+ "select",
69
+ " properties.errorCode as errorCode,",
70
+ " count() as errorCount",
71
+ "from events",
72
+ `where ${buildOperationWhereClause(this.eventPrefix, {
73
+ operations: [operation],
74
+ dateRange
75
+ })} and properties.success = 0`,
76
+ "group by errorCode",
77
+ "order by errorCount desc",
78
+ "limit 5"
79
+ ].join(`
80
+ `),
81
+ values: buildOperationValues({
82
+ operations: [operation],
83
+ dateRange
84
+ })
146
85
  });
86
+ const rows = mapRows(result);
87
+ return rows.reduce((acc, row) => {
88
+ const code = asString(row.errorCode);
89
+ if (!code)
90
+ return acc;
91
+ acc[code] = asNumber(row.errorCount);
92
+ return acc;
93
+ }, {});
147
94
  }
148
- suggestOptimizations(stats, anomalies, lifecycleContext) {
149
- const anomaliesByOp = new Map(this.groupByOperation(anomalies));
150
- const hints = [];
151
- for (const stat of stats) {
152
- const opKey = this.operationKey(stat.operation);
153
- const opAnomalies = anomaliesByOp.get(opKey) ?? [];
154
- for (const anomaly of opAnomalies) {
155
- if (anomaly.metric === "latency") {
156
- hints.push(this.applyLifecycleContext({
157
- operation: stat.operation,
158
- category: "performance",
159
- summary: "Latency regression detected",
160
- justification: `P99 latency at ${stat.p99LatencyMs}ms`,
161
- recommendedActions: [
162
- "Add batching or caching layer",
163
- "Replay golden tests to capture slow inputs"
164
- ]
165
- }, lifecycleContext?.stage));
166
- } else if (anomaly.metric === "error-rate") {
167
- const topError = Object.entries(stat.topErrors).sort((a, b) => b[1] - a[1])[0]?.[0];
168
- hints.push(this.applyLifecycleContext({
169
- operation: stat.operation,
170
- category: "error-handling",
171
- summary: "Error spike detected",
172
- justification: topError ? `Dominant error code ${topError}` : "Increase in failures",
173
- recommendedActions: [
174
- "Generate regression spec from failing payloads",
175
- "Add policy guardrails before rollout"
176
- ]
177
- }, lifecycleContext?.stage));
178
- } else if (anomaly.metric === "throughput") {
179
- hints.push(this.applyLifecycleContext({
180
- operation: stat.operation,
181
- category: "performance",
182
- summary: "Throughput drop detected",
183
- justification: "Significant traffic reduction relative to baseline",
184
- recommendedActions: [
185
- "Validate routing + feature flag bucketing",
186
- "Backfill spec variant to rehydrate demand"
187
- ]
188
- }, lifecycleContext?.stage));
189
- }
190
- }
95
+ async queryHogQL(input) {
96
+ if (!this.reader.queryHogQL) {
97
+ throw new Error("Analytics reader does not support HogQL queries.");
191
98
  }
192
- return hints;
99
+ return this.reader.queryHogQL(input);
193
100
  }
194
- operationKey(op) {
195
- const coordinate = "operation" in op ? op.operation : op;
196
- return `${coordinate.key}.v${coordinate.version}${coordinate.tenantId ? `@${coordinate.tenantId}` : ""}`;
101
+ }
102
+ function buildOperationWhereClause(eventPrefix, input) {
103
+ const clauses = [`event = '${eventPrefix}.operation'`];
104
+ if (input.operations?.length) {
105
+ clauses.push(`(${buildOperationFilters(input.operations)})`);
197
106
  }
198
- buildUsageStats(samples) {
199
- const durations = samples.map((s) => s.durationMs).sort((a, b) => a - b);
200
- const errors = samples.filter((s) => !s.success);
201
- const totalCalls = samples.length;
202
- const successRate = (totalCalls - errors.length) / totalCalls;
203
- const errorRate = errors.length / totalCalls;
204
- const averageLatencyMs = durations.reduce((sum, value) => sum + value, 0) / totalCalls;
205
- const topErrors = errors.reduce((acc, sample) => {
206
- if (!sample.errorCode)
207
- return acc;
208
- acc[sample.errorCode] = (acc[sample.errorCode] ?? 0) + 1;
209
- return acc;
210
- }, {});
211
- const timestamps = samples.map((s) => s.timestamp.getTime());
212
- const windowStart = new Date(Math.min(...timestamps));
213
- const windowEnd = new Date(Math.max(...timestamps));
214
- return {
215
- operation: samples[0].operation,
216
- totalCalls,
217
- successRate,
218
- errorRate,
219
- averageLatencyMs,
220
- p95LatencyMs: percentile(durations, 0.95),
221
- p99LatencyMs: percentile(durations, 0.99),
222
- maxLatencyMs: Math.max(...durations),
223
- lastSeenAt: windowEnd,
224
- windowStart,
225
- windowEnd,
226
- topErrors
227
- };
107
+ if (input.dateRange?.from) {
108
+ clauses.push("timestamp >= {dateFrom}");
228
109
  }
229
- toSeverity(ratio) {
230
- if (ratio >= 2)
231
- return "high";
232
- if (ratio >= 1.3)
233
- return "medium";
234
- return "low";
110
+ if (input.dateRange?.to) {
111
+ clauses.push("timestamp < {dateTo}");
235
112
  }
236
- mapMetricToIntent(metric) {
237
- switch (metric) {
238
- case "error-rate":
239
- return "error-spike";
240
- case "latency":
241
- return "latency-regression";
242
- case "throughput":
243
- return "throughput-drop";
244
- default:
245
- return "schema-mismatch";
113
+ return clauses.join(" and ");
114
+ }
115
+ function buildOperationValues(input) {
116
+ const values = {
117
+ dateFrom: toIsoString(input.dateRange?.from),
118
+ dateTo: toIsoString(input.dateRange?.to)
119
+ };
120
+ input.operations?.forEach((op, index) => {
121
+ values[`operationKey${index}`] = op.key;
122
+ values[`operationVersion${index}`] = op.version;
123
+ if (op.tenantId) {
124
+ values[`operationTenant${index}`] = op.tenantId;
246
125
  }
247
- }
248
- groupByOperation(items) {
249
- const map = new Map;
250
- for (const item of items) {
251
- const key = this.operationKey(item.operation);
252
- const arr = map.get(key) ?? [];
253
- arr.push(item);
254
- map.set(key, arr);
126
+ });
127
+ return values;
128
+ }
129
+ function buildOperationFilters(operations) {
130
+ return operations.map((op, index) => {
131
+ const clauses = [
132
+ `properties.operation = {operationKey${index}}`,
133
+ `properties.version = {operationVersion${index}}`
134
+ ];
135
+ if (op.tenantId) {
136
+ clauses.push(`properties.tenantId = {operationTenant${index}}`);
255
137
  }
256
- return map;
257
- }
258
- applyLifecycleContext(hint, stage) {
259
- if (stage === undefined)
260
- return hint;
261
- const band = mapStageBand(stage);
262
- const advice = LIFECYCLE_HINTS[band]?.[hint.category];
263
- if (!advice) {
264
- return { ...hint, lifecycleStage: stage };
138
+ return `(${clauses.join(" and ")})`;
139
+ }).join(" or ");
140
+ }
141
+ function mapOperationSamples(result) {
142
+ const rows = mapRows(result);
143
+ return rows.flatMap((row) => {
144
+ const operationKey = asString(row.operationKey);
145
+ const version = asString(row.version);
146
+ const timestamp = asDate(row.timestamp);
147
+ if (!operationKey || !version || !timestamp) {
148
+ return [];
265
149
  }
266
- return {
267
- ...hint,
268
- lifecycleStage: stage,
269
- lifecycleNotes: advice.message,
270
- recommendedActions: dedupeActions([
271
- ...hint.recommendedActions,
272
- ...advice.supplementalActions
273
- ])
274
- };
150
+ return [
151
+ {
152
+ operation: {
153
+ key: operationKey,
154
+ version,
155
+ tenantId: asOptionalString(row.tenantId) ?? undefined
156
+ },
157
+ durationMs: asNumber(row.durationMs),
158
+ success: asBoolean(row.success),
159
+ timestamp,
160
+ errorCode: asOptionalString(row.errorCode) ?? undefined,
161
+ traceId: asOptionalString(row.traceId) ?? undefined,
162
+ metadata: isRecord(row.metadata) ? row.metadata : undefined
163
+ }
164
+ ];
165
+ });
166
+ }
167
+ function mapBaselineStats(result, operation, dateRange) {
168
+ const rows = mapRows(result);
169
+ const row = rows[0];
170
+ if (!row)
171
+ return null;
172
+ const totalCalls = asNumber(row.totalCalls);
173
+ if (!totalCalls)
174
+ return null;
175
+ const successCount = asNumber(row.successCount);
176
+ const errorCount = asNumber(row.errorCount);
177
+ return {
178
+ operation,
179
+ totalCalls,
180
+ successRate: totalCalls ? successCount / totalCalls : 0,
181
+ errorRate: totalCalls ? errorCount / totalCalls : 0,
182
+ averageLatencyMs: asNumber(row.averageLatencyMs),
183
+ p95LatencyMs: asNumber(row.p95LatencyMs),
184
+ p99LatencyMs: asNumber(row.p99LatencyMs),
185
+ maxLatencyMs: asNumber(row.maxLatencyMs),
186
+ lastSeenAt: new Date,
187
+ windowStart: toDate(dateRange.from) ?? new Date,
188
+ windowEnd: toDate(dateRange.to) ?? new Date,
189
+ topErrors: {}
190
+ };
191
+ }
192
+ function mapRows(result) {
193
+ if (!Array.isArray(result.results) || !Array.isArray(result.columns)) {
194
+ return [];
275
195
  }
196
+ const columns = result.columns;
197
+ return result.results.flatMap((row) => {
198
+ if (!Array.isArray(row))
199
+ return [];
200
+ const record = {};
201
+ columns.forEach((column, index) => {
202
+ record[column] = row[index];
203
+ });
204
+ return [record];
205
+ });
276
206
  }
277
- function percentile(values, p) {
278
- if (!values.length)
279
- return 0;
280
- if (values.length === 1)
281
- return values[0];
282
- const idx = Math.min(values.length - 1, Math.floor(p * values.length));
283
- return values[idx];
207
+ function buildWindowRange(windowDays) {
208
+ const windowEnd = new Date;
209
+ const windowStart = new Date(windowEnd.getTime() - windowDays * 24 * 60 * 60 * 1000);
210
+ return {
211
+ from: windowStart,
212
+ to: windowEnd
213
+ };
284
214
  }
285
- var mapStageBand = (stage) => {
286
- if (stage <= 2)
287
- return "early";
288
- if (stage === LifecycleStage.ProductMarketFit)
289
- return "pmf";
290
- if (stage === LifecycleStage.GrowthScaleUp || stage === LifecycleStage.ExpansionPlatform) {
291
- return "scale";
292
- }
293
- return "mature";
294
- };
295
- var LIFECYCLE_HINTS = {
296
- early: {
297
- performance: {
298
- message: "Favor guardrails that protect learning velocity before heavy rewrites.",
299
- supplementalActions: [
300
- "Wrap risky changes behind progressive delivery flags"
301
- ]
302
- },
303
- "error-handling": {
304
- message: "Make failures loud and recoverable so you can learn faster.",
305
- supplementalActions: ["Add auto-rollbacks or manual kill switches"]
306
- }
307
- },
308
- pmf: {
309
- performance: {
310
- message: "Stabilize the core use case to avoid regressions while demand grows.",
311
- supplementalActions: ["Instrument regression tests on critical specs"]
312
- }
313
- },
314
- scale: {
315
- performance: {
316
- message: "Prioritize resilience and multi-tenant safety as volumes expand.",
317
- supplementalActions: [
318
- "Introduce workload partitioning or isolation per tenant"
319
- ]
320
- },
321
- "error-handling": {
322
- message: "Contain blast radius with policy fallbacks and circuit breakers.",
323
- supplementalActions: ["Add circuit breakers to high-risk operations"]
324
- }
325
- },
326
- mature: {
327
- performance: {
328
- message: "Optimize for margins and predictable SLAs.",
329
- supplementalActions: [
330
- "Capture unit-cost impacts alongside latency fixes"
331
- ]
332
- },
333
- "error-handling": {
334
- message: "Prevent regressions with automated regression specs before deploy.",
335
- supplementalActions: [
336
- "Run auto-evolution simulations on renewal scenarios"
337
- ]
338
- }
339
- }
340
- };
341
- var dedupeActions = (actions) => {
342
- const seen = new Set;
343
- const ordered = [];
344
- for (const action of actions) {
345
- if (seen.has(action))
346
- continue;
347
- seen.add(action);
348
- ordered.push(action);
349
- }
350
- return ordered;
351
- };
352
- // src/analyzer/posthog-telemetry-reader.ts
353
- class PosthogTelemetryReader {
354
- reader;
355
- eventPrefix;
356
- constructor(reader, options = {}) {
357
- this.reader = reader;
358
- this.eventPrefix = options.eventPrefix ?? "observability";
359
- }
360
- async readOperationSamples(input) {
361
- const result = await this.queryHogQL({
362
- query: [
363
- "select",
364
- " properties.operation as operationKey,",
365
- " properties.version as version,",
366
- " properties.durationMs as durationMs,",
367
- " properties.success as success,",
368
- " properties.errorCode as errorCode,",
369
- " properties.tenantId as tenantId,",
370
- " properties.traceId as traceId,",
371
- " properties.metadata as metadata,",
372
- " timestamp as timestamp",
373
- "from events",
374
- `where ${buildOperationWhereClause(this.eventPrefix, input)}`,
375
- "order by timestamp desc",
376
- `limit ${input.limit ?? 1000}`
377
- ].join(`
378
- `),
379
- values: buildOperationValues(input)
380
- });
381
- return mapOperationSamples(result);
382
- }
383
- async readAnomalyBaseline(operation, windowDays = 7) {
384
- const dateRange = buildWindowRange(windowDays);
385
- const baseResult = await this.queryHogQL({
386
- query: [
387
- "select",
388
- " count() as totalCalls,",
389
- " avg(properties.durationMs) as averageLatencyMs,",
390
- " quantile(0.95)(properties.durationMs) as p95LatencyMs,",
391
- " quantile(0.99)(properties.durationMs) as p99LatencyMs,",
392
- " max(properties.durationMs) as maxLatencyMs,",
393
- " sum(if(properties.success = 1, 1, 0)) as successCount,",
394
- " sum(if(properties.success = 0, 1, 0)) as errorCount",
395
- "from events",
396
- `where ${buildOperationWhereClause(this.eventPrefix, {
397
- operations: [operation],
398
- dateRange
399
- })}`
400
- ].join(`
401
- `),
402
- values: buildOperationValues({
403
- operations: [operation],
404
- dateRange
405
- })
406
- });
407
- const stats = mapBaselineStats(baseResult, operation, dateRange);
408
- if (!stats)
409
- return null;
410
- const topErrors = await this.readTopErrors(operation, dateRange);
411
- return {
412
- ...stats,
413
- topErrors
414
- };
415
- }
416
- async readTopErrors(operation, dateRange) {
417
- const result = await this.queryHogQL({
418
- query: [
419
- "select",
420
- " properties.errorCode as errorCode,",
421
- " count() as errorCount",
422
- "from events",
423
- `where ${buildOperationWhereClause(this.eventPrefix, {
424
- operations: [operation],
425
- dateRange
426
- })} and properties.success = 0`,
427
- "group by errorCode",
428
- "order by errorCount desc",
429
- "limit 5"
430
- ].join(`
431
- `),
432
- values: buildOperationValues({
433
- operations: [operation],
434
- dateRange
435
- })
436
- });
437
- const rows = mapRows(result);
438
- return rows.reduce((acc, row) => {
439
- const code = asString(row.errorCode);
440
- if (!code)
441
- return acc;
442
- acc[code] = asNumber(row.errorCount);
443
- return acc;
444
- }, {});
445
- }
446
- async queryHogQL(input) {
447
- if (!this.reader.queryHogQL) {
448
- throw new Error("Analytics reader does not support HogQL queries.");
449
- }
450
- return this.reader.queryHogQL(input);
215
+ function asString(value) {
216
+ if (typeof value === "string" && value.trim())
217
+ return value;
218
+ if (typeof value === "number")
219
+ return String(value);
220
+ return null;
221
+ }
222
+ function asOptionalString(value) {
223
+ if (typeof value === "string")
224
+ return value;
225
+ if (typeof value === "number")
226
+ return String(value);
227
+ return null;
228
+ }
229
+ function asNumber(value) {
230
+ if (typeof value === "number" && Number.isFinite(value))
231
+ return value;
232
+ if (typeof value === "string" && value.trim()) {
233
+ const parsed = Number(value);
234
+ if (Number.isFinite(parsed))
235
+ return parsed;
451
236
  }
237
+ return 0;
452
238
  }
453
- function buildOperationWhereClause(eventPrefix, input) {
454
- const clauses = [`event = '${eventPrefix}.operation'`];
455
- if (input.operations?.length) {
456
- clauses.push(`(${buildOperationFilters(input.operations)})`);
457
- }
458
- if (input.dateRange?.from) {
459
- clauses.push("timestamp >= {dateFrom}");
460
- }
461
- if (input.dateRange?.to) {
462
- clauses.push("timestamp < {dateTo}");
239
+ function asBoolean(value) {
240
+ if (typeof value === "boolean")
241
+ return value;
242
+ if (typeof value === "number")
243
+ return value !== 0;
244
+ if (typeof value === "string")
245
+ return value.toLowerCase() === "true";
246
+ return false;
247
+ }
248
+ function asDate(value) {
249
+ if (value instanceof Date)
250
+ return value;
251
+ if (typeof value === "string" || typeof value === "number") {
252
+ const date = new Date(value);
253
+ if (!Number.isNaN(date.getTime()))
254
+ return date;
463
255
  }
464
- return clauses.join(" and ");
256
+ return null;
465
257
  }
466
- function buildOperationValues(input) {
467
- const values = {
468
- dateFrom: toIsoString(input.dateRange?.from),
469
- dateTo: toIsoString(input.dateRange?.to)
470
- };
471
- input.operations?.forEach((op, index) => {
472
- values[`operationKey${index}`] = op.key;
473
- values[`operationVersion${index}`] = op.version;
474
- if (op.tenantId) {
475
- values[`operationTenant${index}`] = op.tenantId;
476
- }
477
- });
478
- return values;
258
+ function toIsoString(value) {
259
+ if (!value)
260
+ return;
261
+ return typeof value === "string" ? value : value.toISOString();
479
262
  }
480
- function buildOperationFilters(operations) {
481
- return operations.map((op, index) => {
482
- const clauses = [
483
- `properties.operation = {operationKey${index}}`,
484
- `properties.version = {operationVersion${index}}`
485
- ];
486
- if (op.tenantId) {
487
- clauses.push(`properties.tenantId = {operationTenant${index}}`);
488
- }
489
- return `(${clauses.join(" and ")})`;
490
- }).join(" or ");
263
+ function toDate(value) {
264
+ if (!value)
265
+ return null;
266
+ return value instanceof Date ? value : new Date(value);
491
267
  }
492
- function mapOperationSamples(result) {
493
- const rows = mapRows(result);
494
- return rows.flatMap((row) => {
495
- const operationKey = asString(row.operationKey);
496
- const version = asString(row.version);
497
- const timestamp = asDate(row.timestamp);
498
- if (!operationKey || !version || !timestamp) {
268
+ function isRecord(value) {
269
+ return typeof value === "object" && value !== null;
270
+ }
271
+ // src/analyzer/spec-analyzer.ts
272
+ import { randomUUID } from "node:crypto";
273
+ import { LifecycleStage } from "@contractspec/lib.lifecycle";
274
+ var DEFAULT_OPTIONS = {
275
+ minSampleSize: 50,
276
+ errorRateThreshold: 0.05,
277
+ latencyP99ThresholdMs: 750
278
+ };
279
+
280
+ class SpecAnalyzer {
281
+ logger;
282
+ minSampleSize;
283
+ errorRateThreshold;
284
+ latencyP99ThresholdMs;
285
+ throughputDropThreshold;
286
+ constructor(options = {}) {
287
+ this.logger = options.logger;
288
+ this.minSampleSize = options.minSampleSize ?? DEFAULT_OPTIONS.minSampleSize;
289
+ this.errorRateThreshold = options.errorRateThreshold ?? DEFAULT_OPTIONS.errorRateThreshold;
290
+ this.latencyP99ThresholdMs = options.latencyP99ThresholdMs ?? DEFAULT_OPTIONS.latencyP99ThresholdMs;
291
+ this.throughputDropThreshold = options.throughputDropThreshold ?? 0.2;
292
+ }
293
+ analyzeSpecUsage(samples) {
294
+ if (!samples.length) {
295
+ this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skip", {
296
+ reason: "no-samples"
297
+ });
499
298
  return [];
500
299
  }
501
- return [
502
- {
503
- operation: {
504
- key: operationKey,
505
- version,
506
- tenantId: asOptionalString(row.tenantId) ?? undefined
507
- },
508
- durationMs: asNumber(row.durationMs),
509
- success: asBoolean(row.success),
510
- timestamp,
511
- errorCode: asOptionalString(row.errorCode) ?? undefined,
512
- traceId: asOptionalString(row.traceId) ?? undefined,
513
- metadata: isRecord(row.metadata) ? row.metadata : undefined
514
- }
515
- ];
516
- });
517
- }
518
- function mapBaselineStats(result, operation, dateRange) {
519
- const rows = mapRows(result);
520
- const row = rows[0];
521
- if (!row)
522
- return null;
523
- const totalCalls = asNumber(row.totalCalls);
524
- if (!totalCalls)
525
- return null;
526
- const successCount = asNumber(row.successCount);
527
- const errorCount = asNumber(row.errorCount);
528
- return {
529
- operation,
530
- totalCalls,
531
- successRate: totalCalls ? successCount / totalCalls : 0,
532
- errorRate: totalCalls ? errorCount / totalCalls : 0,
533
- averageLatencyMs: asNumber(row.averageLatencyMs),
534
- p95LatencyMs: asNumber(row.p95LatencyMs),
535
- p99LatencyMs: asNumber(row.p99LatencyMs),
536
- maxLatencyMs: asNumber(row.maxLatencyMs),
537
- lastSeenAt: new Date,
538
- windowStart: toDate(dateRange.from) ?? new Date,
539
- windowEnd: toDate(dateRange.to) ?? new Date,
540
- topErrors: {}
541
- };
542
- }
543
- function mapRows(result) {
544
- if (!Array.isArray(result.results) || !Array.isArray(result.columns)) {
545
- return [];
300
+ const groups = new Map;
301
+ for (const sample of samples) {
302
+ const key = this.operationKey(sample);
303
+ const arr = groups.get(key) ?? [];
304
+ arr.push(sample);
305
+ groups.set(key, arr);
306
+ }
307
+ const entries = [...groups.values()];
308
+ const usable = entries.filter((samplesForOp) => {
309
+ const valid = samplesForOp.length >= this.minSampleSize;
310
+ if (!valid) {
311
+ this.logger?.debug("SpecAnalyzer.analyzeSpecUsage.skipOperation", {
312
+ operation: this.operationKey(samplesForOp[0]),
313
+ sampleSize: samplesForOp.length,
314
+ minSampleSize: this.minSampleSize
315
+ });
316
+ }
317
+ return valid;
318
+ });
319
+ return usable.map((operationSamples) => this.buildUsageStats(operationSamples));
546
320
  }
547
- const columns = result.columns;
548
- return result.results.flatMap((row) => {
549
- if (!Array.isArray(row))
550
- return [];
551
- const record = {};
552
- columns.forEach((column, index) => {
553
- record[column] = row[index];
321
+ detectAnomalies(stats, baseline) {
322
+ const anomalies = [];
323
+ if (!stats.length) {
324
+ this.logger?.debug("SpecAnalyzer.detectAnomalies.skip", {
325
+ reason: "no-stats"
326
+ });
327
+ return anomalies;
328
+ }
329
+ const baselineByOp = new Map((baseline ?? []).map((item) => [this.operationKey(item.operation), item]));
330
+ for (const stat of stats) {
331
+ const evidence = [];
332
+ if (stat.errorRate >= this.errorRateThreshold) {
333
+ evidence.push({
334
+ type: "telemetry",
335
+ description: `Error rate ${stat.errorRate.toFixed(2)} exceeded threshold ${this.errorRateThreshold}`,
336
+ data: { errorRate: stat.errorRate }
337
+ });
338
+ anomalies.push({
339
+ operation: stat.operation,
340
+ severity: this.toSeverity(stat.errorRate / this.errorRateThreshold),
341
+ metric: "error-rate",
342
+ description: "Error rate spike",
343
+ detectedAt: new Date,
344
+ threshold: this.errorRateThreshold,
345
+ observedValue: stat.errorRate,
346
+ evidence
347
+ });
348
+ continue;
349
+ }
350
+ if (stat.p99LatencyMs >= this.latencyP99ThresholdMs) {
351
+ evidence.push({
352
+ type: "telemetry",
353
+ description: `P99 latency ${stat.p99LatencyMs}ms exceeded threshold ${this.latencyP99ThresholdMs}ms`,
354
+ data: { p99LatencyMs: stat.p99LatencyMs }
355
+ });
356
+ anomalies.push({
357
+ operation: stat.operation,
358
+ severity: this.toSeverity(stat.p99LatencyMs / this.latencyP99ThresholdMs),
359
+ metric: "latency",
360
+ description: "Latency regression detected",
361
+ detectedAt: new Date,
362
+ threshold: this.latencyP99ThresholdMs,
363
+ observedValue: stat.p99LatencyMs,
364
+ evidence
365
+ });
366
+ continue;
367
+ }
368
+ const baselineStat = baselineByOp.get(this.operationKey(stat.operation));
369
+ if (baselineStat) {
370
+ const drop = (baselineStat.totalCalls - stat.totalCalls) / baselineStat.totalCalls;
371
+ if (drop >= this.throughputDropThreshold) {
372
+ evidence.push({
373
+ type: "telemetry",
374
+ description: `Throughput dropped by ${(drop * 100).toFixed(1)}% compared to baseline`,
375
+ data: {
376
+ baselineCalls: baselineStat.totalCalls,
377
+ currentCalls: stat.totalCalls
378
+ }
379
+ });
380
+ anomalies.push({
381
+ operation: stat.operation,
382
+ severity: this.toSeverity(drop / this.throughputDropThreshold),
383
+ metric: "throughput",
384
+ description: "Usage drop detected",
385
+ detectedAt: new Date,
386
+ threshold: this.throughputDropThreshold,
387
+ observedValue: drop,
388
+ evidence
389
+ });
390
+ }
391
+ }
392
+ }
393
+ return anomalies;
394
+ }
395
+ toIntentPatterns(anomalies, stats) {
396
+ const statsByOp = new Map(stats.map((item) => [this.operationKey(item.operation), item]));
397
+ return anomalies.map((anomaly) => {
398
+ const stat = statsByOp.get(this.operationKey(anomaly.operation));
399
+ const confidence = {
400
+ score: Math.min(1, (anomaly.observedValue ?? 0) / (anomaly.threshold ?? 1)),
401
+ sampleSize: stat?.totalCalls ?? 0,
402
+ pValue: undefined
403
+ };
404
+ return {
405
+ id: randomUUID(),
406
+ type: this.mapMetricToIntent(anomaly.metric),
407
+ description: anomaly.description,
408
+ operation: anomaly.operation,
409
+ confidence,
410
+ metadata: {
411
+ observedValue: anomaly.observedValue,
412
+ threshold: anomaly.threshold
413
+ },
414
+ evidence: anomaly.evidence
415
+ };
554
416
  });
555
- return [record];
556
- });
557
- }
558
- function buildWindowRange(windowDays) {
559
- const windowEnd = new Date;
560
- const windowStart = new Date(windowEnd.getTime() - windowDays * 24 * 60 * 60 * 1000);
561
- return {
562
- from: windowStart,
563
- to: windowEnd
564
- };
565
- }
566
- function asString(value) {
567
- if (typeof value === "string" && value.trim())
568
- return value;
569
- if (typeof value === "number")
570
- return String(value);
571
- return null;
572
- }
573
- function asOptionalString(value) {
574
- if (typeof value === "string")
575
- return value;
576
- if (typeof value === "number")
577
- return String(value);
578
- return null;
579
- }
580
- function asNumber(value) {
581
- if (typeof value === "number" && Number.isFinite(value))
582
- return value;
583
- if (typeof value === "string" && value.trim()) {
584
- const parsed = Number(value);
585
- if (Number.isFinite(parsed))
586
- return parsed;
587
417
  }
588
- return 0;
589
- }
590
- function asBoolean(value) {
591
- if (typeof value === "boolean")
592
- return value;
593
- if (typeof value === "number")
594
- return value !== 0;
595
- if (typeof value === "string")
596
- return value.toLowerCase() === "true";
597
- return false;
598
- }
599
- function asDate(value) {
600
- if (value instanceof Date)
601
- return value;
602
- if (typeof value === "string" || typeof value === "number") {
603
- const date = new Date(value);
604
- if (!Number.isNaN(date.getTime()))
605
- return date;
418
+ suggestOptimizations(stats, anomalies, lifecycleContext) {
419
+ const anomaliesByOp = new Map(this.groupByOperation(anomalies));
420
+ const hints = [];
421
+ for (const stat of stats) {
422
+ const opKey = this.operationKey(stat.operation);
423
+ const opAnomalies = anomaliesByOp.get(opKey) ?? [];
424
+ for (const anomaly of opAnomalies) {
425
+ if (anomaly.metric === "latency") {
426
+ hints.push(this.applyLifecycleContext({
427
+ operation: stat.operation,
428
+ category: "performance",
429
+ summary: "Latency regression detected",
430
+ justification: `P99 latency at ${stat.p99LatencyMs}ms`,
431
+ recommendedActions: [
432
+ "Add batching or caching layer",
433
+ "Replay golden tests to capture slow inputs"
434
+ ]
435
+ }, lifecycleContext?.stage));
436
+ } else if (anomaly.metric === "error-rate") {
437
+ const topError = Object.entries(stat.topErrors).sort((a, b) => b[1] - a[1])[0]?.[0];
438
+ hints.push(this.applyLifecycleContext({
439
+ operation: stat.operation,
440
+ category: "error-handling",
441
+ summary: "Error spike detected",
442
+ justification: topError ? `Dominant error code ${topError}` : "Increase in failures",
443
+ recommendedActions: [
444
+ "Generate regression spec from failing payloads",
445
+ "Add policy guardrails before rollout"
446
+ ]
447
+ }, lifecycleContext?.stage));
448
+ } else if (anomaly.metric === "throughput") {
449
+ hints.push(this.applyLifecycleContext({
450
+ operation: stat.operation,
451
+ category: "performance",
452
+ summary: "Throughput drop detected",
453
+ justification: "Significant traffic reduction relative to baseline",
454
+ recommendedActions: [
455
+ "Validate routing + feature flag bucketing",
456
+ "Backfill spec variant to rehydrate demand"
457
+ ]
458
+ }, lifecycleContext?.stage));
459
+ }
460
+ }
461
+ }
462
+ return hints;
606
463
  }
607
- return null;
608
- }
609
- function toIsoString(value) {
610
- if (!value)
611
- return;
612
- return typeof value === "string" ? value : value.toISOString();
613
- }
614
- function toDate(value) {
615
- if (!value)
616
- return null;
617
- return value instanceof Date ? value : new Date(value);
618
- }
619
- function isRecord(value) {
620
- return typeof value === "object" && value !== null;
621
- }
622
- // src/generator/spec-generator.ts
623
- import { randomUUID as randomUUID2 } from "node:crypto";
624
-
625
- class SpecGenerator {
626
- config;
627
- logger;
628
- clock;
629
- getSpec;
630
- constructor(options = {}) {
631
- this.config = options.config ?? {};
632
- this.logger = options.logger;
633
- this.clock = options.clock ?? (() => new Date);
634
- this.getSpec = options.getSpec;
464
+ operationKey(op) {
465
+ const coordinate = "operation" in op ? op.operation : op;
466
+ return `${coordinate.key}.v${coordinate.version}${coordinate.tenantId ? `@${coordinate.tenantId}` : ""}`;
635
467
  }
636
- generateFromIntent(intent, options = {}) {
637
- const now = this.clock();
638
- const summary = options.summary ?? `${this.intentToVerb(intent.type)} ${intent.operation?.key ?? "operation"}`;
639
- const rationale = options.rationale ?? [
640
- intent.description,
641
- intent.metadata?.observedValue ? `Observed ${intent.metadata.observedValue}` : undefined
642
- ].filter(Boolean).join(" ");
643
- const suggestion = {
644
- id: randomUUID2(),
645
- intent,
646
- target: intent.operation,
647
- proposal: {
648
- summary,
649
- rationale,
650
- changeType: options.changeType ?? this.inferChangeType(intent),
651
- kind: options.kind,
652
- spec: options.spec,
653
- diff: options.diff,
654
- metadata: options.metadata
655
- },
656
- confidence: intent.confidence.score,
657
- priority: this.intentToPriority(intent),
658
- createdAt: now,
659
- createdBy: options.createdBy ?? "auto-evolution",
660
- status: options.status ?? "pending",
661
- evidence: intent.evidence,
662
- tags: options.tags
468
+ buildUsageStats(samples) {
469
+ const durations = samples.map((s) => s.durationMs).sort((a, b) => a - b);
470
+ const errors = samples.filter((s) => !s.success);
471
+ const totalCalls = samples.length;
472
+ const successRate = (totalCalls - errors.length) / totalCalls;
473
+ const errorRate = errors.length / totalCalls;
474
+ const averageLatencyMs = durations.reduce((sum, value) => sum + value, 0) / totalCalls;
475
+ const topErrors = errors.reduce((acc, sample) => {
476
+ if (!sample.errorCode)
477
+ return acc;
478
+ acc[sample.errorCode] = (acc[sample.errorCode] ?? 0) + 1;
479
+ return acc;
480
+ }, {});
481
+ const timestamps = samples.map((s) => s.timestamp.getTime());
482
+ const windowStart = new Date(Math.min(...timestamps));
483
+ const windowEnd = new Date(Math.max(...timestamps));
484
+ return {
485
+ operation: samples[0].operation,
486
+ totalCalls,
487
+ successRate,
488
+ errorRate,
489
+ averageLatencyMs,
490
+ p95LatencyMs: percentile(durations, 0.95),
491
+ p99LatencyMs: percentile(durations, 0.99),
492
+ maxLatencyMs: Math.max(...durations),
493
+ lastSeenAt: windowEnd,
494
+ windowStart,
495
+ windowEnd,
496
+ topErrors
497
+ };
498
+ }
499
+ toSeverity(ratio) {
500
+ if (ratio >= 2)
501
+ return "high";
502
+ if (ratio >= 1.3)
503
+ return "medium";
504
+ return "low";
505
+ }
506
+ mapMetricToIntent(metric) {
507
+ switch (metric) {
508
+ case "error-rate":
509
+ return "error-spike";
510
+ case "latency":
511
+ return "latency-regression";
512
+ case "throughput":
513
+ return "throughput-drop";
514
+ default:
515
+ return "schema-mismatch";
516
+ }
517
+ }
518
+ groupByOperation(items) {
519
+ const map = new Map;
520
+ for (const item of items) {
521
+ const key = this.operationKey(item.operation);
522
+ const arr = map.get(key) ?? [];
523
+ arr.push(item);
524
+ map.set(key, arr);
525
+ }
526
+ return map;
527
+ }
528
+ applyLifecycleContext(hint, stage) {
529
+ if (stage === undefined)
530
+ return hint;
531
+ const band = mapStageBand(stage);
532
+ const advice = LIFECYCLE_HINTS[band]?.[hint.category];
533
+ if (!advice) {
534
+ return { ...hint, lifecycleStage: stage };
535
+ }
536
+ return {
537
+ ...hint,
538
+ lifecycleStage: stage,
539
+ lifecycleNotes: advice.message,
540
+ recommendedActions: dedupeActions([
541
+ ...hint.recommendedActions,
542
+ ...advice.supplementalActions
543
+ ])
663
544
  };
664
- return suggestion;
665
545
  }
666
- generateVariant(operation, patch, intent, options = {}) {
667
- if (!this.getSpec) {
668
- throw new Error("SpecGenerator requires getSpec() to generate variants");
669
- }
670
- const base = this.getSpec(operation.key, operation.version);
671
- if (!base) {
672
- throw new Error(`Cannot generate variant; spec ${operation.key}.v${operation.version} not found`);
673
- }
674
- const merged = mergeContract(base, patch);
675
- return this.generateFromIntent(intent, { ...options, spec: merged });
546
+ }
547
+ function percentile(values, p) {
548
+ if (!values.length)
549
+ return 0;
550
+ if (values.length === 1)
551
+ return values[0];
552
+ const idx = Math.min(values.length - 1, Math.floor(p * values.length));
553
+ return values[idx];
554
+ }
555
+ var mapStageBand = (stage) => {
556
+ if (stage <= 2)
557
+ return "early";
558
+ if (stage === LifecycleStage.ProductMarketFit)
559
+ return "pmf";
560
+ if (stage === LifecycleStage.GrowthScaleUp || stage === LifecycleStage.ExpansionPlatform) {
561
+ return "scale";
676
562
  }
677
- validateSuggestion(suggestion, config = this.config) {
678
- const reasons = [];
679
- if (config.minConfidence != null && suggestion.confidence < config.minConfidence) {
680
- reasons.push(`Confidence ${suggestion.confidence.toFixed(2)} below minimum ${config.minConfidence}`);
563
+ return "mature";
564
+ };
565
+ var LIFECYCLE_HINTS = {
566
+ early: {
567
+ performance: {
568
+ message: "Favor guardrails that protect learning velocity before heavy rewrites.",
569
+ supplementalActions: [
570
+ "Wrap risky changes behind progressive delivery flags"
571
+ ]
572
+ },
573
+ "error-handling": {
574
+ message: "Make failures loud and recoverable so you can learn faster.",
575
+ supplementalActions: ["Add auto-rollbacks or manual kill switches"]
681
576
  }
682
- if (config.requireApproval && suggestion.status === "approved") {
683
- reasons.push("Suggestion cannot be auto-approved when approval is required");
577
+ },
578
+ pmf: {
579
+ performance: {
580
+ message: "Stabilize the core use case to avoid regressions while demand grows.",
581
+ supplementalActions: ["Instrument regression tests on critical specs"]
684
582
  }
685
- if (suggestion.proposal.spec && !suggestion.proposal.spec.meta?.key) {
686
- reasons.push("Proposal spec must include meta.key");
583
+ },
584
+ scale: {
585
+ performance: {
586
+ message: "Prioritize resilience and multi-tenant safety as volumes expand.",
587
+ supplementalActions: [
588
+ "Introduce workload partitioning or isolation per tenant"
589
+ ]
590
+ },
591
+ "error-handling": {
592
+ message: "Contain blast radius with policy fallbacks and circuit breakers.",
593
+ supplementalActions: ["Add circuit breakers to high-risk operations"]
687
594
  }
688
- if (!suggestion.proposal.summary) {
689
- reasons.push("Proposal summary is required");
595
+ },
596
+ mature: {
597
+ performance: {
598
+ message: "Optimize for margins and predictable SLAs.",
599
+ supplementalActions: [
600
+ "Capture unit-cost impacts alongside latency fixes"
601
+ ]
602
+ },
603
+ "error-handling": {
604
+ message: "Prevent regressions with automated regression specs before deploy.",
605
+ supplementalActions: [
606
+ "Run auto-evolution simulations on renewal scenarios"
607
+ ]
690
608
  }
691
- const ok = reasons.length === 0;
692
- if (!ok) {
693
- this.logger?.warn("SpecGenerator.validateSuggestion.failed", {
694
- suggestionId: suggestion.id,
695
- reasons
609
+ }
610
+ };
611
+ var dedupeActions = (actions) => {
612
+ const seen = new Set;
613
+ const ordered = [];
614
+ for (const action of actions) {
615
+ if (seen.has(action))
616
+ continue;
617
+ seen.add(action);
618
+ ordered.push(action);
619
+ }
620
+ return ordered;
621
+ };
622
+ // src/approval/integration.ts
623
+ import { mkdir, writeFile } from "node:fs/promises";
624
+ import { join } from "node:path";
625
+
626
+ class SpecSuggestionOrchestrator {
627
+ options;
628
+ constructor(options) {
629
+ this.options = options;
630
+ }
631
+ async submit(suggestion, session, approvalReason) {
632
+ await this.options.repository.create(suggestion);
633
+ if (session && this.options.approval) {
634
+ await this.options.approval.requestApproval({
635
+ sessionId: session.sessionId,
636
+ agentId: session.agentId,
637
+ tenantId: session.tenantId,
638
+ toolName: "evolution_apply_suggestion",
639
+ toolCallId: suggestion.id,
640
+ toolArgs: { suggestionId: suggestion.id },
641
+ reason: approvalReason ?? suggestion.proposal.summary,
642
+ payload: { suggestionId: suggestion.id }
696
643
  });
697
644
  }
698
- return { ok, reasons };
645
+ return suggestion;
699
646
  }
700
- intentToVerb(intent) {
701
- switch (intent) {
702
- case "error-spike":
703
- return "Stabilize";
704
- case "latency-regression":
705
- return "Optimize";
706
- case "missing-operation":
707
- return "Introduce";
708
- case "throughput-drop":
709
- return "Rebalance";
710
- default:
711
- return "Adjust";
647
+ async approve(id, reviewer, notes) {
648
+ const suggestion = await this.ensureSuggestion(id);
649
+ await this.options.repository.updateStatus(id, "approved", {
650
+ reviewer,
651
+ notes,
652
+ decidedAt: new Date
653
+ });
654
+ if (this.options.writer) {
655
+ await this.options.writer.write({
656
+ ...suggestion,
657
+ status: "approved",
658
+ approvals: {
659
+ reviewer,
660
+ notes,
661
+ decidedAt: new Date,
662
+ status: "approved"
663
+ }
664
+ });
712
665
  }
713
666
  }
714
- intentToPriority(intent) {
715
- const severity = intent.confidence.score;
716
- if (intent.type === "error-spike" || severity >= 0.8)
717
- return "high";
718
- if (severity >= 0.5)
719
- return "medium";
720
- return "low";
667
+ async reject(id, reviewer, notes) {
668
+ await this.options.repository.updateStatus(id, "rejected", {
669
+ reviewer,
670
+ notes,
671
+ decidedAt: new Date
672
+ });
721
673
  }
722
- inferChangeType(intent) {
723
- switch (intent.type) {
724
- case "missing-operation":
725
- return "new-spec";
726
- case "schema-mismatch":
727
- return "schema-update";
728
- case "error-spike":
729
- return "policy-update";
730
- default:
731
- return "revision";
732
- }
674
+ list(filters) {
675
+ return this.options.repository.list(filters);
676
+ }
677
+ async ensureSuggestion(id) {
678
+ const suggestion = await this.options.repository.getById(id);
679
+ if (!suggestion)
680
+ throw new Error(`Spec suggestion ${id} not found`);
681
+ return suggestion;
733
682
  }
734
683
  }
735
- function mergeContract(base, patch) {
736
- return {
737
- ...base,
738
- ...patch,
739
- meta: { ...base.meta, ...patch.meta },
740
- io: {
741
- ...base.io,
742
- ...patch.io
743
- },
744
- policy: {
745
- ...base.policy,
746
- ...patch.policy
747
- },
748
- telemetry: {
749
- ...base.telemetry,
750
- ...patch.telemetry
684
+
685
+ class FileSystemSuggestionWriter {
686
+ outputDir;
687
+ filenameTemplate;
688
+ constructor(options = {}) {
689
+ this.outputDir = options.outputDir ?? join(process.cwd(), "packages/libs/contracts-spec/src/generated");
690
+ this.filenameTemplate = options.filenameTemplate ?? ((suggestion) => `${suggestion.target?.key ?? suggestion.intent.id}.v${suggestion.target?.version ?? "next"}.suggestion.json`);
691
+ }
692
+ async write(suggestion) {
693
+ await mkdir(this.outputDir, { recursive: true });
694
+ const filename = this.filenameTemplate(suggestion);
695
+ const filepath = join(this.outputDir, filename);
696
+ const payload = serializeSuggestion(suggestion);
697
+ await writeFile(filepath, JSON.stringify(payload, null, 2));
698
+ return filepath;
699
+ }
700
+ }
701
+
702
+ class InMemorySpecSuggestionRepository {
703
+ items = new Map;
704
+ async create(suggestion) {
705
+ this.items.set(suggestion.id, suggestion);
706
+ }
707
+ async getById(id) {
708
+ return this.items.get(id);
709
+ }
710
+ async updateStatus(id, status, metadata) {
711
+ const suggestion = await this.getById(id);
712
+ if (!suggestion)
713
+ return;
714
+ this.items.set(id, {
715
+ ...suggestion,
716
+ status,
717
+ approvals: {
718
+ reviewer: metadata?.reviewer,
719
+ notes: metadata?.notes,
720
+ decidedAt: metadata?.decidedAt,
721
+ status
722
+ }
723
+ });
724
+ }
725
+ async list(filters) {
726
+ const values = [...this.items.values()];
727
+ if (!filters)
728
+ return values;
729
+ return values.filter((item) => {
730
+ if (filters.status && item.status !== filters.status)
731
+ return false;
732
+ if (filters.operationKey && item.target?.key !== filters.operationKey)
733
+ return false;
734
+ return true;
735
+ });
736
+ }
737
+ }
738
+ function serializeSuggestion(suggestion) {
739
+ const { proposal, ...rest } = suggestion;
740
+ const { spec, ...proposalRest } = proposal;
741
+ return {
742
+ ...rest,
743
+ proposal: {
744
+ ...proposalRest,
745
+ specMeta: spec?.meta
751
746
  },
752
- sideEffects: {
753
- ...base.sideEffects,
754
- ...patch.sideEffects
747
+ createdAt: suggestion.createdAt.toISOString(),
748
+ intent: {
749
+ ...suggestion.intent,
750
+ confidence: { ...suggestion.intent.confidence },
751
+ evidence: suggestion.intent.evidence
755
752
  }
756
753
  };
757
754
  }
758
755
  // src/generator/ai-spec-generator.ts
756
+ import { randomUUID as randomUUID2 } from "node:crypto";
759
757
  import { generateText, Output } from "ai";
760
758
  import * as z from "zod";
761
- import { randomUUID as randomUUID3 } from "node:crypto";
762
759
  var SpecSuggestionProposalSchema = z.object({
763
760
  summary: z.string().describe("Brief summary of the proposed change"),
764
761
  rationale: z.string().describe("Detailed explanation of why this change is needed"),
@@ -905,7 +902,7 @@ Please provide an improved version with more specific recommendations.`;
905
902
  }
906
903
  };
907
904
  return {
908
- id: randomUUID3(),
905
+ id: randomUUID2(),
909
906
  intent,
910
907
  target: intent.operation,
911
908
  proposal,
@@ -939,136 +936,139 @@ Please provide an improved version with more specific recommendations.`;
939
936
  function createAISpecGenerator(config) {
940
937
  return new AISpecGenerator(config);
941
938
  }
942
- // src/approval/integration.ts
943
- import { mkdir, writeFile } from "node:fs/promises";
944
- import { join } from "node:path";
939
+ // src/generator/spec-generator.ts
940
+ import { randomUUID as randomUUID3 } from "node:crypto";
945
941
 
946
- class SpecSuggestionOrchestrator {
947
- options;
948
- constructor(options) {
949
- this.options = options;
942
+ class SpecGenerator {
943
+ config;
944
+ logger;
945
+ clock;
946
+ getSpec;
947
+ constructor(options = {}) {
948
+ this.config = options.config ?? {};
949
+ this.logger = options.logger;
950
+ this.clock = options.clock ?? (() => new Date);
951
+ this.getSpec = options.getSpec;
950
952
  }
951
- async submit(suggestion, session, approvalReason) {
952
- await this.options.repository.create(suggestion);
953
- if (session && this.options.approval) {
954
- await this.options.approval.requestApproval({
955
- sessionId: session.sessionId,
956
- agentId: session.agentId,
957
- tenantId: session.tenantId,
958
- toolName: "evolution_apply_suggestion",
959
- toolCallId: suggestion.id,
960
- toolArgs: { suggestionId: suggestion.id },
961
- reason: approvalReason ?? suggestion.proposal.summary,
962
- payload: { suggestionId: suggestion.id }
963
- });
964
- }
953
+ generateFromIntent(intent, options = {}) {
954
+ const now = this.clock();
955
+ const summary = options.summary ?? `${this.intentToVerb(intent.type)} ${intent.operation?.key ?? "operation"}`;
956
+ const rationale = options.rationale ?? [
957
+ intent.description,
958
+ intent.metadata?.observedValue ? `Observed ${intent.metadata.observedValue}` : undefined
959
+ ].filter(Boolean).join(" — ");
960
+ const suggestion = {
961
+ id: randomUUID3(),
962
+ intent,
963
+ target: intent.operation,
964
+ proposal: {
965
+ summary,
966
+ rationale,
967
+ changeType: options.changeType ?? this.inferChangeType(intent),
968
+ kind: options.kind,
969
+ spec: options.spec,
970
+ diff: options.diff,
971
+ metadata: options.metadata
972
+ },
973
+ confidence: intent.confidence.score,
974
+ priority: this.intentToPriority(intent),
975
+ createdAt: now,
976
+ createdBy: options.createdBy ?? "auto-evolution",
977
+ status: options.status ?? "pending",
978
+ evidence: intent.evidence,
979
+ tags: options.tags
980
+ };
965
981
  return suggestion;
966
982
  }
967
- async approve(id, reviewer, notes) {
968
- const suggestion = await this.ensureSuggestion(id);
969
- await this.options.repository.updateStatus(id, "approved", {
970
- reviewer,
971
- notes,
972
- decidedAt: new Date
973
- });
974
- if (this.options.writer) {
975
- await this.options.writer.write({
976
- ...suggestion,
977
- status: "approved",
978
- approvals: {
979
- reviewer,
980
- notes,
981
- decidedAt: new Date,
982
- status: "approved"
983
- }
984
- });
983
+ generateVariant(operation, patch, intent, options = {}) {
984
+ if (!this.getSpec) {
985
+ throw new Error("SpecGenerator requires getSpec() to generate variants");
985
986
  }
987
+ const base = this.getSpec(operation.key, operation.version);
988
+ if (!base) {
989
+ throw new Error(`Cannot generate variant; spec ${operation.key}.v${operation.version} not found`);
990
+ }
991
+ const merged = mergeContract(base, patch);
992
+ return this.generateFromIntent(intent, { ...options, spec: merged });
986
993
  }
987
- async reject(id, reviewer, notes) {
988
- await this.options.repository.updateStatus(id, "rejected", {
989
- reviewer,
990
- notes,
991
- decidedAt: new Date
992
- });
993
- }
994
- list(filters) {
995
- return this.options.repository.list(filters);
996
- }
997
- async ensureSuggestion(id) {
998
- const suggestion = await this.options.repository.getById(id);
999
- if (!suggestion)
1000
- throw new Error(`Spec suggestion ${id} not found`);
1001
- return suggestion;
1002
- }
1003
- }
1004
-
1005
- class FileSystemSuggestionWriter {
1006
- outputDir;
1007
- filenameTemplate;
1008
- constructor(options = {}) {
1009
- this.outputDir = options.outputDir ?? join(process.cwd(), "packages/libs/contracts-spec/src/generated");
1010
- this.filenameTemplate = options.filenameTemplate ?? ((suggestion) => `${suggestion.target?.key ?? suggestion.intent.id}.v${suggestion.target?.version ?? "next"}.suggestion.json`);
1011
- }
1012
- async write(suggestion) {
1013
- await mkdir(this.outputDir, { recursive: true });
1014
- const filename = this.filenameTemplate(suggestion);
1015
- const filepath = join(this.outputDir, filename);
1016
- const payload = serializeSuggestion(suggestion);
1017
- await writeFile(filepath, JSON.stringify(payload, null, 2));
1018
- return filepath;
1019
- }
1020
- }
1021
-
1022
- class InMemorySpecSuggestionRepository {
1023
- items = new Map;
1024
- async create(suggestion) {
1025
- this.items.set(suggestion.id, suggestion);
994
+ validateSuggestion(suggestion, config = this.config) {
995
+ const reasons = [];
996
+ if (config.minConfidence != null && suggestion.confidence < config.minConfidence) {
997
+ reasons.push(`Confidence ${suggestion.confidence.toFixed(2)} below minimum ${config.minConfidence}`);
998
+ }
999
+ if (config.requireApproval && suggestion.status === "approved") {
1000
+ reasons.push("Suggestion cannot be auto-approved when approval is required");
1001
+ }
1002
+ if (suggestion.proposal.spec && !suggestion.proposal.spec.meta?.key) {
1003
+ reasons.push("Proposal spec must include meta.key");
1004
+ }
1005
+ if (!suggestion.proposal.summary) {
1006
+ reasons.push("Proposal summary is required");
1007
+ }
1008
+ const ok = reasons.length === 0;
1009
+ if (!ok) {
1010
+ this.logger?.warn("SpecGenerator.validateSuggestion.failed", {
1011
+ suggestionId: suggestion.id,
1012
+ reasons
1013
+ });
1014
+ }
1015
+ return { ok, reasons };
1026
1016
  }
1027
- async getById(id) {
1028
- return this.items.get(id);
1017
+ intentToVerb(intent) {
1018
+ switch (intent) {
1019
+ case "error-spike":
1020
+ return "Stabilize";
1021
+ case "latency-regression":
1022
+ return "Optimize";
1023
+ case "missing-operation":
1024
+ return "Introduce";
1025
+ case "throughput-drop":
1026
+ return "Rebalance";
1027
+ default:
1028
+ return "Adjust";
1029
+ }
1029
1030
  }
1030
- async updateStatus(id, status, metadata) {
1031
- const suggestion = await this.getById(id);
1032
- if (!suggestion)
1033
- return;
1034
- this.items.set(id, {
1035
- ...suggestion,
1036
- status,
1037
- approvals: {
1038
- reviewer: metadata?.reviewer,
1039
- notes: metadata?.notes,
1040
- decidedAt: metadata?.decidedAt,
1041
- status
1042
- }
1043
- });
1031
+ intentToPriority(intent) {
1032
+ const severity = intent.confidence.score;
1033
+ if (intent.type === "error-spike" || severity >= 0.8)
1034
+ return "high";
1035
+ if (severity >= 0.5)
1036
+ return "medium";
1037
+ return "low";
1044
1038
  }
1045
- async list(filters) {
1046
- const values = [...this.items.values()];
1047
- if (!filters)
1048
- return values;
1049
- return values.filter((item) => {
1050
- if (filters.status && item.status !== filters.status)
1051
- return false;
1052
- if (filters.operationKey && item.target?.key !== filters.operationKey)
1053
- return false;
1054
- return true;
1055
- });
1039
+ inferChangeType(intent) {
1040
+ switch (intent.type) {
1041
+ case "missing-operation":
1042
+ return "new-spec";
1043
+ case "schema-mismatch":
1044
+ return "schema-update";
1045
+ case "error-spike":
1046
+ return "policy-update";
1047
+ default:
1048
+ return "revision";
1049
+ }
1056
1050
  }
1057
1051
  }
1058
- function serializeSuggestion(suggestion) {
1059
- const { proposal, ...rest } = suggestion;
1060
- const { spec, ...proposalRest } = proposal;
1052
+ function mergeContract(base, patch) {
1061
1053
  return {
1062
- ...rest,
1063
- proposal: {
1064
- ...proposalRest,
1065
- specMeta: spec?.meta
1054
+ ...base,
1055
+ ...patch,
1056
+ meta: { ...base.meta, ...patch.meta },
1057
+ io: {
1058
+ ...base.io,
1059
+ ...patch.io
1066
1060
  },
1067
- createdAt: suggestion.createdAt.toISOString(),
1068
- intent: {
1069
- ...suggestion.intent,
1070
- confidence: { ...suggestion.intent.confidence },
1071
- evidence: suggestion.intent.evidence
1061
+ policy: {
1062
+ ...base.policy,
1063
+ ...patch.policy
1064
+ },
1065
+ telemetry: {
1066
+ ...base.telemetry,
1067
+ ...patch.telemetry
1068
+ },
1069
+ sideEffects: {
1070
+ ...base.sideEffects,
1071
+ ...patch.sideEffects
1072
1072
  }
1073
1073
  };
1074
1074
  }