@massu/core 0.1.0 → 0.1.1

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 (114) hide show
  1. package/LICENSE +71 -0
  2. package/dist/hooks/cost-tracker.js +127 -11493
  3. package/dist/hooks/post-edit-context.js +125 -11491
  4. package/dist/hooks/post-tool-use.js +127 -11493
  5. package/dist/hooks/pre-compact.js +127 -11493
  6. package/dist/hooks/pre-delete-check.js +126 -11492
  7. package/dist/hooks/quality-event.js +127 -11493
  8. package/dist/hooks/session-end.js +127 -11493
  9. package/dist/hooks/session-start.js +127 -11493
  10. package/dist/hooks/user-prompt.js +127 -11493
  11. package/package.json +9 -8
  12. package/src/__tests__/adr-generator.test.ts +260 -0
  13. package/src/__tests__/analytics.test.ts +282 -0
  14. package/src/__tests__/audit-trail.test.ts +382 -0
  15. package/src/__tests__/backfill-sessions.test.ts +690 -0
  16. package/src/__tests__/cli.test.ts +290 -0
  17. package/src/__tests__/cloud-sync.test.ts +261 -0
  18. package/src/__tests__/config-sections.test.ts +359 -0
  19. package/src/__tests__/config.test.ts +732 -0
  20. package/src/__tests__/cost-tracker.test.ts +348 -0
  21. package/src/__tests__/db.test.ts +177 -0
  22. package/src/__tests__/dependency-scorer.test.ts +325 -0
  23. package/src/__tests__/docs-integration.test.ts +178 -0
  24. package/src/__tests__/docs-tools.test.ts +199 -0
  25. package/src/__tests__/domains.test.ts +236 -0
  26. package/src/__tests__/hooks.test.ts +221 -0
  27. package/src/__tests__/import-resolver.test.ts +95 -0
  28. package/src/__tests__/integration/path-traversal.test.ts +134 -0
  29. package/src/__tests__/integration/pricing-consistency.test.ts +88 -0
  30. package/src/__tests__/integration/tool-registration.test.ts +146 -0
  31. package/src/__tests__/memory-db.test.ts +404 -0
  32. package/src/__tests__/memory-enhancements.test.ts +316 -0
  33. package/src/__tests__/memory-tools.test.ts +199 -0
  34. package/src/__tests__/middleware-tree.test.ts +177 -0
  35. package/src/__tests__/observability-tools.test.ts +595 -0
  36. package/src/__tests__/observability.test.ts +437 -0
  37. package/src/__tests__/observation-extractor.test.ts +167 -0
  38. package/src/__tests__/page-deps.test.ts +60 -0
  39. package/src/__tests__/prompt-analyzer.test.ts +298 -0
  40. package/src/__tests__/regression-detector.test.ts +295 -0
  41. package/src/__tests__/rules.test.ts +87 -0
  42. package/src/__tests__/schema-mapper.test.ts +29 -0
  43. package/src/__tests__/security-scorer.test.ts +238 -0
  44. package/src/__tests__/security-utils.test.ts +175 -0
  45. package/src/__tests__/sentinel-db.test.ts +491 -0
  46. package/src/__tests__/sentinel-scanner.test.ts +750 -0
  47. package/src/__tests__/sentinel-tools.test.ts +324 -0
  48. package/src/__tests__/sentinel-types.test.ts +750 -0
  49. package/src/__tests__/server.test.ts +452 -0
  50. package/src/__tests__/session-archiver.test.ts +524 -0
  51. package/src/__tests__/session-state-generator.test.ts +900 -0
  52. package/src/__tests__/team-knowledge.test.ts +327 -0
  53. package/src/__tests__/tools.test.ts +340 -0
  54. package/src/__tests__/transcript-parser.test.ts +195 -0
  55. package/src/__tests__/trpc-index.test.ts +25 -0
  56. package/src/__tests__/validate-features-runner.test.ts +517 -0
  57. package/src/__tests__/validation-engine.test.ts +300 -0
  58. package/src/adr-generator.ts +285 -0
  59. package/src/analytics.ts +367 -0
  60. package/src/audit-trail.ts +443 -0
  61. package/src/backfill-sessions.ts +180 -0
  62. package/src/cli.ts +105 -0
  63. package/src/cloud-sync.ts +194 -0
  64. package/src/commands/doctor.ts +300 -0
  65. package/src/commands/init.ts +399 -0
  66. package/src/commands/install-hooks.ts +26 -0
  67. package/src/config.ts +357 -0
  68. package/src/core-tools.ts +685 -0
  69. package/src/cost-tracker.ts +350 -0
  70. package/src/db.ts +233 -0
  71. package/src/dependency-scorer.ts +330 -0
  72. package/src/docs-map.json +100 -0
  73. package/src/docs-tools.ts +514 -0
  74. package/src/domains.ts +181 -0
  75. package/src/hooks/cost-tracker.ts +66 -0
  76. package/src/hooks/intent-suggester.ts +131 -0
  77. package/src/hooks/post-edit-context.ts +91 -0
  78. package/src/hooks/post-tool-use.ts +175 -0
  79. package/src/hooks/pre-compact.ts +146 -0
  80. package/src/hooks/pre-delete-check.ts +153 -0
  81. package/src/hooks/quality-event.ts +127 -0
  82. package/src/hooks/security-gate.ts +121 -0
  83. package/src/hooks/session-end.ts +467 -0
  84. package/src/hooks/session-start.ts +210 -0
  85. package/src/hooks/user-prompt.ts +91 -0
  86. package/src/import-resolver.ts +224 -0
  87. package/src/memory-db.ts +48 -0
  88. package/src/memory-queries.ts +804 -0
  89. package/src/memory-schema.ts +546 -0
  90. package/src/memory-tools.ts +392 -0
  91. package/src/middleware-tree.ts +70 -0
  92. package/src/observability-tools.ts +332 -0
  93. package/src/observation-extractor.ts +411 -0
  94. package/src/page-deps.ts +283 -0
  95. package/src/prompt-analyzer.ts +325 -0
  96. package/src/regression-detector.ts +313 -0
  97. package/src/rules.ts +57 -0
  98. package/src/schema-mapper.ts +232 -0
  99. package/src/security-scorer.ts +398 -0
  100. package/src/security-utils.ts +133 -0
  101. package/src/sentinel-db.ts +623 -0
  102. package/src/sentinel-scanner.ts +405 -0
  103. package/src/sentinel-tools.ts +515 -0
  104. package/src/sentinel-types.ts +140 -0
  105. package/src/server.ts +190 -0
  106. package/src/session-archiver.ts +112 -0
  107. package/src/session-state-generator.ts +174 -0
  108. package/src/team-knowledge.ts +400 -0
  109. package/src/tool-helpers.ts +41 -0
  110. package/src/tools.ts +111 -0
  111. package/src/transcript-parser.ts +458 -0
  112. package/src/trpc-index.ts +214 -0
  113. package/src/validate-features-runner.ts +107 -0
  114. package/src/validation-engine.ts +351 -0
@@ -0,0 +1,325 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import type Database from 'better-sqlite3';
5
+ import type { ToolDefinition, ToolResult } from './tool-helpers.ts';
6
+ import { p, text } from './tool-helpers.ts';
7
+ import { createHash } from 'crypto';
8
+ import { getConfig } from './config.ts';
9
+ import { escapeRegex, redactSensitiveContent } from './security-utils.ts';
10
+
11
+ // ============================================================
12
+ // Prompt Effectiveness Analysis
13
+ // ============================================================
14
+
15
+ /** Default success/failure indicators. Can be overridden via config.analytics.prompts */
16
+ const DEFAULT_SUCCESS_INDICATORS = ['committed', 'approved', 'looks good', 'perfect', 'great', 'thanks'];
17
+ const DEFAULT_FAILURE_INDICATORS = ['revert', 'wrong', "that's not", 'undo', 'incorrect'];
18
+ const DEFAULT_ABANDON_PATTERNS = /\b(nevermind|forget it|skip|let's move on|different|instead)\b/i;
19
+
20
+ /**
21
+ * Categorize a prompt by its intent.
22
+ */
23
+ export function categorizePrompt(promptText: string): string {
24
+ const lower = promptText.toLowerCase();
25
+
26
+ if (/\b(fix|bug|error|broken|issue|crash|fail)\b/.test(lower)) return 'bugfix';
27
+ if (/\b(refactor|rename|move|extract|cleanup|reorganize)\b/.test(lower)) return 'refactor';
28
+ if (/\b(what|how|why|where|when|explain|describe|tell me)\b/.test(lower)) return 'question';
29
+ if (/^\/\w+/.test(promptText.trim())) return 'command';
30
+ if (/\b(add|create|implement|build|new|feature)\b/.test(lower)) return 'feature';
31
+
32
+ return 'feature'; // Default to feature for implementation requests
33
+ }
34
+
35
+ /**
36
+ * Hash a prompt for deduplication/comparison.
37
+ * Normalizes whitespace and lowercases before hashing.
38
+ */
39
+ export function hashPrompt(promptText: string): string {
40
+ const normalized = promptText.toLowerCase().replace(/\s+/g, ' ').trim();
41
+ return createHash('sha256').update(normalized).digest('hex').slice(0, 16);
42
+ }
43
+
44
+ /**
45
+ * Detect outcome from subsequent conversation context.
46
+ * Heuristic based on what follows a prompt.
47
+ */
48
+ export function detectOutcome(
49
+ followUpPrompts: string[],
50
+ assistantResponses: string[]
51
+ ): { outcome: string; correctionsNeeded: number; followUpCount: number } {
52
+ let correctionsNeeded = 0;
53
+ let outcome = 'success';
54
+
55
+ const correctionPatterns = /\b(no|wrong|that's not|fix this|try again|revert|undo|incorrect|not what)\b/i;
56
+
57
+ const config = getConfig();
58
+ const successIndicators = config.analytics?.prompts?.success_indicators ?? DEFAULT_SUCCESS_INDICATORS;
59
+ // Escape regex special chars from config-provided indicators to prevent ReDoS
60
+ const escapedIndicators = successIndicators.map(escapeRegex);
61
+ const successRegex = new RegExp(`\\b(${escapedIndicators.join('|')})\\b`, 'i');
62
+
63
+ for (const prompt of followUpPrompts) {
64
+ if (correctionPatterns.test(prompt)) {
65
+ correctionsNeeded++;
66
+ }
67
+ if (DEFAULT_ABANDON_PATTERNS.test(prompt)) {
68
+ outcome = 'abandoned';
69
+ break;
70
+ }
71
+ }
72
+
73
+ // Check assistant responses for failure signals
74
+ for (const response of assistantResponses) {
75
+ if (/\b(error|failed|cannot|unable to)\b/i.test(response) && response.length < 200) {
76
+ outcome = 'failure';
77
+ }
78
+ }
79
+
80
+ // Determine final outcome
81
+ if (outcome === 'abandoned') {
82
+ // Keep abandoned
83
+ } else if (correctionsNeeded >= 3) {
84
+ outcome = 'partial';
85
+ } else if (correctionsNeeded > 0) {
86
+ outcome = 'partial';
87
+ } else {
88
+ // Check for success signals in follow-ups
89
+ for (const prompt of followUpPrompts) {
90
+ if (successRegex.test(prompt)) {
91
+ outcome = 'success';
92
+ break;
93
+ }
94
+ }
95
+ }
96
+
97
+ return {
98
+ outcome,
99
+ correctionsNeeded,
100
+ followUpCount: followUpPrompts.length,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Analyze prompts from a session and store outcomes.
106
+ */
107
+ export function analyzeSessionPrompts(db: Database.Database, sessionId: string): number {
108
+ const prompts = db.prepare(
109
+ 'SELECT prompt_text, prompt_number FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC'
110
+ ).all(sessionId) as Array<{ prompt_text: string; prompt_number: number }>;
111
+
112
+ if (prompts.length === 0) return 0;
113
+
114
+ let stored = 0;
115
+ for (let i = 0; i < prompts.length; i++) {
116
+ const prompt = prompts[i];
117
+ const followUps = prompts.slice(i + 1, i + 4).map(p => p.prompt_text);
118
+
119
+ const category = categorizePrompt(prompt.prompt_text);
120
+ const hash = hashPrompt(prompt.prompt_text);
121
+ const { outcome, correctionsNeeded, followUpCount } = detectOutcome(followUps, []);
122
+
123
+ // Check if already analyzed
124
+ const existing = db.prepare(
125
+ 'SELECT id FROM prompt_outcomes WHERE session_id = ? AND prompt_hash = ?'
126
+ ).get(sessionId, hash);
127
+ if (existing) continue;
128
+
129
+ // Redact sensitive content (API keys, emails, tokens, paths) before storage
130
+ const redactedText = redactSensitiveContent(prompt.prompt_text.slice(0, 2000));
131
+
132
+ db.prepare(`
133
+ INSERT INTO prompt_outcomes
134
+ (session_id, prompt_hash, prompt_text, prompt_category, word_count, outcome,
135
+ corrections_needed, follow_up_prompts)
136
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
137
+ `).run(
138
+ sessionId, hash, redactedText, category,
139
+ prompt.prompt_text.split(/\s+/).length, outcome,
140
+ correctionsNeeded, followUpCount
141
+ );
142
+ stored++;
143
+ }
144
+
145
+ return stored;
146
+ }
147
+
148
+ // ============================================================
149
+ // MCP Tool Definitions & Handlers
150
+ // ============================================================
151
+
152
+ export function getPromptToolDefinitions(): ToolDefinition[] {
153
+ return [
154
+ {
155
+ name: p('prompt_effectiveness'),
156
+ description: 'Prompt effectiveness statistics by category. Shows success rates, average corrections needed, and best-performing prompt patterns.',
157
+ inputSchema: {
158
+ type: 'object',
159
+ properties: {
160
+ category: {
161
+ type: 'string',
162
+ description: 'Filter by category: feature, bugfix, refactor, question, command',
163
+ },
164
+ days: { type: 'number', description: 'Days to look back (default: 30)' },
165
+ },
166
+ required: [],
167
+ },
168
+ },
169
+ {
170
+ name: p('prompt_suggestions'),
171
+ description: 'Suggest improvements for a prompt based on past outcomes. Finds similar prompts ranked by success rate.',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ prompt: { type: 'string', description: 'The prompt text to analyze' },
176
+ },
177
+ required: ['prompt'],
178
+ },
179
+ },
180
+ ];
181
+ }
182
+
183
+ const PROMPT_BASE_NAMES = new Set(['prompt_effectiveness', 'prompt_suggestions']);
184
+
185
+ export function isPromptTool(name: string): boolean {
186
+ const pfx = getConfig().toolPrefix + '_';
187
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
188
+ return PROMPT_BASE_NAMES.has(baseName);
189
+ }
190
+
191
+ export function handlePromptToolCall(
192
+ name: string,
193
+ args: Record<string, unknown>,
194
+ memoryDb: Database.Database
195
+ ): ToolResult {
196
+ try {
197
+ const pfx = getConfig().toolPrefix + '_';
198
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
199
+
200
+ switch (baseName) {
201
+ case 'prompt_effectiveness':
202
+ return handleEffectiveness(args, memoryDb);
203
+ case 'prompt_suggestions':
204
+ return handleSuggestions(args, memoryDb);
205
+ default:
206
+ return text(`Unknown prompt tool: ${name}`);
207
+ }
208
+ } catch (error) {
209
+ return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}\n\nUsage: ${p('prompt_effectiveness')} { days: 30 }, ${p('prompt_suggestions')} { prompt: "..." }`);
210
+ }
211
+ }
212
+
213
+ function handleEffectiveness(args: Record<string, unknown>, db: Database.Database): ToolResult {
214
+ const category = args.category as string | undefined;
215
+ const days = (args.days as number) ?? 30;
216
+
217
+ let sql = `
218
+ SELECT prompt_category,
219
+ COUNT(*) as total,
220
+ SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes,
221
+ SUM(CASE WHEN outcome = 'partial' THEN 1 ELSE 0 END) as partials,
222
+ SUM(CASE WHEN outcome = 'failure' THEN 1 ELSE 0 END) as failures,
223
+ SUM(CASE WHEN outcome = 'abandoned' THEN 1 ELSE 0 END) as abandoned,
224
+ AVG(corrections_needed) as avg_corrections,
225
+ AVG(word_count) as avg_word_count
226
+ FROM prompt_outcomes
227
+ WHERE created_at >= datetime('now', ?)
228
+ `;
229
+ const params: (string | number)[] = [`-${days} days`];
230
+
231
+ if (category) {
232
+ sql += ' AND prompt_category = ?';
233
+ params.push(category);
234
+ }
235
+
236
+ sql += ' GROUP BY prompt_category ORDER BY total DESC';
237
+
238
+ const rows = db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
239
+
240
+ if (rows.length === 0) {
241
+ return text(`No prompt outcomes found in the last ${days} days. Prompt analysis runs automatically at session end. Try a longer time range: ${p('prompt_effectiveness')} { days: 90 }, or use ${p('prompt_suggestions')} { prompt: "your text" } to analyze a prompt directly.`);
242
+ }
243
+
244
+ const lines = [
245
+ `## Prompt Effectiveness (${days} days)`,
246
+ '',
247
+ '| Category | Total | Success % | Partial | Failed | Abandoned | Avg Corrections | Avg Words |',
248
+ '|----------|-------|-----------|---------|--------|-----------|-----------------|-----------|',
249
+ ];
250
+
251
+ for (const row of rows) {
252
+ const total = row.total as number;
253
+ const successRate = total > 0 ? Math.round(((row.successes as number) / total) * 100) : 0;
254
+ lines.push(
255
+ `| ${row.prompt_category} | ${total} | ${successRate}% | ${row.partials} | ${row.failures} | ${row.abandoned} | ${(row.avg_corrections as number).toFixed(1)} | ${Math.round(row.avg_word_count as number)} |`
256
+ );
257
+ }
258
+
259
+ return text(lines.join('\n'));
260
+ }
261
+
262
+ function handleSuggestions(args: Record<string, unknown>, db: Database.Database): ToolResult {
263
+ const prompt = args.prompt as string;
264
+ if (!prompt) return text(`Usage: ${p('prompt_suggestions')} { prompt: "your prompt text here" } - Analyzes a prompt and suggests improvements based on past outcomes.`);
265
+
266
+ const category = categorizePrompt(prompt);
267
+ const wordCount = prompt.split(/\s+/).length;
268
+
269
+ // Find successful prompts in the same category with similar length
270
+ const similar = db.prepare(`
271
+ SELECT prompt_text, outcome, corrections_needed, word_count
272
+ FROM prompt_outcomes
273
+ WHERE prompt_category = ? AND outcome = 'success'
274
+ ORDER BY ABS(word_count - ?) ASC
275
+ LIMIT 5
276
+ `).all(category, wordCount) as Array<{
277
+ prompt_text: string;
278
+ outcome: string;
279
+ corrections_needed: number;
280
+ word_count: number;
281
+ }>;
282
+
283
+ const lines = [
284
+ `## Prompt Analysis`,
285
+ `Category: ${category}`,
286
+ `Word count: ${wordCount}`,
287
+ '',
288
+ ];
289
+
290
+ // Suggestions based on patterns
291
+ if (wordCount < 10) {
292
+ lines.push('**Suggestion**: Short prompts often need follow-up corrections. Consider adding more context about:');
293
+ lines.push('- Expected behavior or output');
294
+ lines.push('- Specific files or components to modify');
295
+ lines.push('- Constraints or patterns to follow');
296
+ lines.push('');
297
+ }
298
+
299
+ if (similar.length > 0) {
300
+ lines.push('### Successful Similar Prompts');
301
+ for (const s of similar) {
302
+ lines.push(`- [${s.word_count} words] ${s.prompt_text.slice(0, 150)}...`);
303
+ }
304
+ } else {
305
+ lines.push('No similar successful prompts found in this category.');
306
+ }
307
+
308
+ // Category-specific stats
309
+ const stats = db.prepare(`
310
+ SELECT COUNT(*) as total,
311
+ SUM(CASE WHEN outcome = 'success' THEN 1 ELSE 0 END) as successes,
312
+ AVG(corrections_needed) as avg_corrections
313
+ FROM prompt_outcomes WHERE prompt_category = ?
314
+ `).get(category) as { total: number; successes: number; avg_corrections: number };
315
+
316
+ if (stats.total > 0) {
317
+ lines.push('');
318
+ lines.push(`### Category Stats: ${category}`);
319
+ lines.push(`- Success rate: ${Math.round((stats.successes / stats.total) * 100)}%`);
320
+ lines.push(`- Avg corrections needed: ${stats.avg_corrections.toFixed(1)}`);
321
+ }
322
+
323
+ return text(lines.join('\n'));
324
+ }
325
+
@@ -0,0 +1,313 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import type Database from 'better-sqlite3';
5
+ import type { ToolDefinition, ToolResult } from './tool-helpers.ts';
6
+ import { p, text } from './tool-helpers.ts';
7
+ import { getConfig } from './config.ts';
8
+
9
+ // ============================================================
10
+ // Regression Detection
11
+ // ============================================================
12
+
13
+ /** Default health thresholds. Configurable via regression.health_thresholds */
14
+ const DEFAULT_HEALTH_THRESHOLDS = {
15
+ healthy: 80,
16
+ warning: 50,
17
+ };
18
+
19
+ /**
20
+ * Get health thresholds from config or defaults.
21
+ */
22
+ function getHealthThresholds(): { healthy: number; warning: number } {
23
+ const configured = getConfig().regression?.health_thresholds;
24
+ return {
25
+ healthy: configured?.healthy ?? DEFAULT_HEALTH_THRESHOLDS.healthy,
26
+ warning: configured?.warning ?? DEFAULT_HEALTH_THRESHOLDS.warning,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Calculate feature health score based on modification/test gaps.
32
+ * 0 = critical, 100 = healthy.
33
+ */
34
+ export function calculateHealthScore(
35
+ testsPassing: number,
36
+ testsFailing: number,
37
+ modificationsSinceTest: number,
38
+ lastTested: string | null,
39
+ lastModified: string | null
40
+ ): number {
41
+ let score = 100;
42
+
43
+ // Test failures
44
+ if (testsFailing > 0) {
45
+ score -= Math.min(40, testsFailing * 10);
46
+ }
47
+
48
+ // Modifications since last test
49
+ if (modificationsSinceTest > 0) {
50
+ score -= Math.min(30, modificationsSinceTest * 5);
51
+ }
52
+
53
+ // Time gap between modification and test
54
+ if (lastModified && lastTested) {
55
+ const modDate = new Date(lastModified).getTime();
56
+ const testDate = new Date(lastTested).getTime();
57
+ if (modDate > testDate) {
58
+ const daysSinceTest = (modDate - testDate) / (1000 * 60 * 60 * 24);
59
+ score -= Math.min(20, Math.floor(daysSinceTest * 2));
60
+ }
61
+ } else if (lastModified && !lastTested) {
62
+ // Modified but never tested
63
+ score -= 30;
64
+ }
65
+
66
+ return Math.max(0, score);
67
+ }
68
+
69
+ /**
70
+ * Update feature health when a file is modified.
71
+ */
72
+ export function trackModification(
73
+ db: Database.Database,
74
+ featureKey: string
75
+ ): void {
76
+ const existing = db.prepare(
77
+ 'SELECT * FROM feature_health WHERE feature_key = ?'
78
+ ).get(featureKey) as Record<string, unknown> | undefined;
79
+
80
+ if (existing) {
81
+ db.prepare(`
82
+ UPDATE feature_health
83
+ SET last_modified = datetime('now'),
84
+ modifications_since_test = modifications_since_test + 1,
85
+ health_score = ?
86
+ WHERE feature_key = ?
87
+ `).run(
88
+ calculateHealthScore(
89
+ (existing.tests_passing as number) ?? 0,
90
+ (existing.tests_failing as number) ?? 0,
91
+ ((existing.modifications_since_test as number) ?? 0) + 1,
92
+ existing.last_tested as string | null,
93
+ new Date().toISOString()
94
+ ),
95
+ featureKey
96
+ );
97
+ } else {
98
+ db.prepare(`
99
+ INSERT INTO feature_health
100
+ (feature_key, last_modified, modifications_since_test, health_score, tests_passing, tests_failing)
101
+ VALUES (?, datetime('now'), 1, 70, 0, 0)
102
+ `).run(featureKey);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Record test results for a feature.
108
+ */
109
+ export function recordTestResult(
110
+ db: Database.Database,
111
+ featureKey: string,
112
+ passing: number,
113
+ failing: number
114
+ ): void {
115
+ const existing = db.prepare(
116
+ 'SELECT * FROM feature_health WHERE feature_key = ?'
117
+ ).get(featureKey) as Record<string, unknown> | undefined;
118
+
119
+ const healthScore = calculateHealthScore(passing, failing, 0, new Date().toISOString(), existing?.last_modified as string | null);
120
+
121
+ db.prepare(`
122
+ INSERT INTO feature_health
123
+ (feature_key, last_tested, test_coverage_pct, health_score, tests_passing, tests_failing, modifications_since_test)
124
+ VALUES (?, datetime('now'), ?, ?, ?, ?, 0)
125
+ ON CONFLICT(feature_key) DO UPDATE SET
126
+ last_tested = datetime('now'),
127
+ health_score = ?,
128
+ tests_passing = ?,
129
+ tests_failing = ?,
130
+ modifications_since_test = 0
131
+ `).run(
132
+ featureKey, passing > 0 ? (passing / (passing + failing)) * 100 : 0,
133
+ healthScore, passing, failing,
134
+ healthScore, passing, failing
135
+ );
136
+ }
137
+
138
+ /**
139
+ * Build alerts for unhealthy features.
140
+ */
141
+ function buildAlerts(feature: Record<string, unknown>): string[] {
142
+ const alerts: string[] = [];
143
+
144
+ if ((feature.tests_failing as number) > 0) {
145
+ alerts.push(`${feature.tests_failing} tests failing`);
146
+ }
147
+ if ((feature.modifications_since_test as number) > 3) {
148
+ alerts.push(`${feature.modifications_since_test} modifications since last test`);
149
+ }
150
+ if (!feature.last_tested && feature.last_modified) {
151
+ alerts.push('Never tested');
152
+ }
153
+
154
+ return alerts;
155
+ }
156
+
157
+ // ============================================================
158
+ // MCP Tool Definitions & Handlers
159
+ // ============================================================
160
+
161
+ export function getRegressionToolDefinitions(): ToolDefinition[] {
162
+ return [
163
+ {
164
+ name: p('feature_health'),
165
+ description: 'Feature health dashboard. Shows health scores, modification/test gaps, and alerts for registered features.',
166
+ inputSchema: {
167
+ type: 'object',
168
+ properties: {
169
+ unhealthy_only: { type: 'boolean', description: 'Show only features with health below warning threshold (default: false)' },
170
+ },
171
+ required: [],
172
+ },
173
+ },
174
+ {
175
+ name: p('regression_risk'),
176
+ description: 'Check if recent changes risk regression. Shows affected features, test coverage status, and risk assessment.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {},
180
+ required: [],
181
+ },
182
+ },
183
+ ];
184
+ }
185
+
186
+ const REGRESSION_BASE_NAMES = new Set(['feature_health', 'regression_risk']);
187
+
188
+ export function isRegressionTool(name: string): boolean {
189
+ const pfx = getConfig().toolPrefix + '_';
190
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
191
+ return REGRESSION_BASE_NAMES.has(baseName);
192
+ }
193
+
194
+ export function handleRegressionToolCall(
195
+ name: string,
196
+ args: Record<string, unknown>,
197
+ memoryDb: Database.Database
198
+ ): ToolResult {
199
+ try {
200
+ const pfx = getConfig().toolPrefix + '_';
201
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
202
+
203
+ switch (baseName) {
204
+ case 'feature_health':
205
+ return handleFeatureHealth(args, memoryDb);
206
+ case 'regression_risk':
207
+ return handleRegressionCheck(args, memoryDb);
208
+ default:
209
+ return text(`Unknown regression tool: ${name}`);
210
+ }
211
+ } catch (error) {
212
+ return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}\n\nUsage: ${p('feature_health')} { unhealthy_only: true }, ${p('regression_risk')} {}`);
213
+ }
214
+ }
215
+
216
+ function handleFeatureHealth(args: Record<string, unknown>, db: Database.Database): ToolResult {
217
+ const unhealthyOnly = args.unhealthy_only as boolean | undefined;
218
+ const thresholds = getHealthThresholds();
219
+
220
+ let sql = 'SELECT * FROM feature_health';
221
+ const params: (string | number)[] = [];
222
+
223
+ if (unhealthyOnly) {
224
+ sql += ' WHERE health_score < ?';
225
+ params.push(thresholds.healthy);
226
+ }
227
+
228
+ sql += ' ORDER BY health_score ASC';
229
+
230
+ const features = db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
231
+
232
+ if (features.length === 0) {
233
+ const filterMsg = unhealthyOnly
234
+ ? `No unhealthy features found (threshold: ${thresholds.healthy}). All tracked features are currently healthy. Use ${p('feature_health')} {} without filters to see all features.`
235
+ : `No feature health data available yet. Feature health is tracked automatically when files in registered features are modified and tested. Try: ${p('regression_risk')} {} to check for untested modifications.`;
236
+ return text(filterMsg);
237
+ }
238
+
239
+ const lines = [
240
+ `## Feature Health Dashboard`,
241
+ `Features tracked: ${features.length}`,
242
+ '',
243
+ '| Feature | Health | Tests P/F | Mods Since Test | Alerts |',
244
+ '|---------|--------|-----------|-----------------|--------|',
245
+ ];
246
+
247
+ for (const f of features) {
248
+ const alerts = buildAlerts(f);
249
+ const healthScore = f.health_score as number;
250
+ const healthIndicator = healthScore >= thresholds.healthy ? 'OK'
251
+ : healthScore >= thresholds.warning ? 'WARN'
252
+ : 'CRIT';
253
+
254
+ lines.push(
255
+ `| ${f.feature_key} | ${healthScore} [${healthIndicator}] | ${f.tests_passing ?? 0}/${f.tests_failing ?? 0} | ${f.modifications_since_test ?? 0} | ${alerts.join('; ') || '-'} |`
256
+ );
257
+ }
258
+
259
+ return text(lines.join('\n'));
260
+ }
261
+
262
+ function handleRegressionCheck(_args: Record<string, unknown>, db: Database.Database): ToolResult {
263
+ const thresholds = getHealthThresholds();
264
+
265
+ const recentlyModified = db.prepare(`
266
+ SELECT feature_key, health_score, modifications_since_test, tests_failing, last_modified, last_tested
267
+ FROM feature_health
268
+ WHERE modifications_since_test > 0
269
+ ORDER BY modifications_since_test DESC
270
+ LIMIT 500
271
+ `).all() as Array<Record<string, unknown>>;
272
+
273
+ if (recentlyModified.length === 0) {
274
+ return text(`No features have been modified since their last test run. Low regression risk. Use ${p('feature_health')} {} to see the full feature health dashboard.`);
275
+ }
276
+
277
+ const highRisk = recentlyModified.filter(f => (f.health_score as number) < thresholds.warning);
278
+ const mediumRisk = recentlyModified.filter(f => (f.health_score as number) >= thresholds.warning && (f.health_score as number) < thresholds.healthy);
279
+ const lowRisk = recentlyModified.filter(f => (f.health_score as number) >= thresholds.healthy);
280
+
281
+ const lines = [
282
+ `## Regression Risk Assessment`,
283
+ `Features with untested modifications: ${recentlyModified.length}`,
284
+ `High risk: ${highRisk.length} | Medium: ${mediumRisk.length} | Low: ${lowRisk.length}`,
285
+ '',
286
+ ];
287
+
288
+ if (highRisk.length > 0) {
289
+ lines.push('### HIGH RISK (test immediately)');
290
+ for (const f of highRisk) {
291
+ lines.push(`- **${f.feature_key}** (health: ${f.health_score}, ${f.modifications_since_test} untested modifications)`);
292
+ }
293
+ lines.push('');
294
+ }
295
+
296
+ if (mediumRisk.length > 0) {
297
+ lines.push('### Medium Risk');
298
+ for (const f of mediumRisk) {
299
+ lines.push(`- ${f.feature_key} (health: ${f.health_score}, ${f.modifications_since_test} untested modifications)`);
300
+ }
301
+ lines.push('');
302
+ }
303
+
304
+ if (lowRisk.length > 0) {
305
+ lines.push('### Low Risk');
306
+ for (const f of lowRisk) {
307
+ lines.push(`- ${f.feature_key} (health: ${f.health_score})`);
308
+ }
309
+ }
310
+
311
+ return text(lines.join('\n'));
312
+ }
313
+
package/src/rules.ts ADDED
@@ -0,0 +1,57 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ import { getConfig } from './config.ts';
5
+
6
+ export interface PatternRule {
7
+ /** Glob pattern to match file paths against */
8
+ match: string;
9
+ /** List of rules that apply to matched files */
10
+ rules: string[];
11
+ /** Severity: CRITICAL rules are schema mismatches or Edge Runtime violations */
12
+ severity?: 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
13
+ /** Pattern file to reference for details */
14
+ patternFile?: string;
15
+ }
16
+
17
+ /**
18
+ * Get pattern rules from config.
19
+ * Converts the config format (pattern/rules) to the internal PatternRule format.
20
+ */
21
+ function getPatternRules(): PatternRule[] {
22
+ return getConfig().rules.map((r) => ({
23
+ match: r.pattern,
24
+ rules: r.rules,
25
+ }));
26
+ }
27
+
28
+ /**
29
+ * Match a file path against all pattern rules and return applicable rules.
30
+ */
31
+ export function matchRules(filePath: string): PatternRule[] {
32
+ const normalized = filePath.replace(/\\/g, '/');
33
+ const rules = getPatternRules();
34
+ return rules.filter((rule) => globMatch(normalized, rule.match));
35
+ }
36
+
37
+ /**
38
+ * Simple glob matching for pattern rules.
39
+ * Supports **, *, and ? wildcards.
40
+ */
41
+ export function globMatch(filePath: string, pattern: string): boolean {
42
+ // Convert glob to regex using placeholders to avoid replacement conflicts
43
+ let regexStr = pattern
44
+ .replace(/\*\*\//g, '\0GLOBSTARSLASH\0') // **/ placeholder
45
+ .replace(/\*\*/g, '\0GLOBSTAR\0') // ** placeholder
46
+ .replace(/\*/g, '\0STAR\0') // * placeholder
47
+ .replace(/\?/g, '\0QUESTION\0') // ? placeholder
48
+ .replace(/\./g, '\\.') // escape dots
49
+ .replace(/\0GLOBSTARSLASH\0/g, '(?:.*/)?') // **/ = zero or more directories
50
+ .replace(/\0GLOBSTAR\0/g, '.*') // ** = anything
51
+ .replace(/\0STAR\0/g, '[^/]*') // * = non-slash chars
52
+ .replace(/\0QUESTION\0/g, '.'); // ? = single char
53
+
54
+ // Anchor pattern
55
+ const regex = new RegExp(`(^|/)${regexStr}($|/)`);
56
+ return regex.test(filePath);
57
+ }