@massu/core 0.1.0 → 0.1.2

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 (67) hide show
  1. package/LICENSE +71 -0
  2. package/README.md +2 -2
  3. package/dist/hooks/cost-tracker.js +149 -11527
  4. package/dist/hooks/post-edit-context.js +127 -11493
  5. package/dist/hooks/post-tool-use.js +169 -11550
  6. package/dist/hooks/pre-compact.js +149 -11530
  7. package/dist/hooks/pre-delete-check.js +144 -11523
  8. package/dist/hooks/quality-event.js +149 -11527
  9. package/dist/hooks/session-end.js +188 -11570
  10. package/dist/hooks/session-start.js +159 -11534
  11. package/dist/hooks/user-prompt.js +149 -11530
  12. package/package.json +14 -19
  13. package/src/adr-generator.ts +292 -0
  14. package/src/analytics.ts +373 -0
  15. package/src/audit-trail.ts +450 -0
  16. package/src/backfill-sessions.ts +180 -0
  17. package/src/cli.ts +105 -0
  18. package/src/cloud-sync.ts +190 -0
  19. package/src/commands/doctor.ts +300 -0
  20. package/src/commands/init.ts +395 -0
  21. package/src/commands/install-hooks.ts +26 -0
  22. package/src/config.ts +357 -0
  23. package/src/cost-tracker.ts +355 -0
  24. package/src/db.ts +233 -0
  25. package/src/dependency-scorer.ts +337 -0
  26. package/src/docs-map.json +100 -0
  27. package/src/docs-tools.ts +517 -0
  28. package/src/domains.ts +181 -0
  29. package/src/hooks/cost-tracker.ts +66 -0
  30. package/src/hooks/intent-suggester.ts +131 -0
  31. package/src/hooks/post-edit-context.ts +91 -0
  32. package/src/hooks/post-tool-use.ts +175 -0
  33. package/src/hooks/pre-compact.ts +146 -0
  34. package/src/hooks/pre-delete-check.ts +153 -0
  35. package/src/hooks/quality-event.ts +127 -0
  36. package/src/hooks/security-gate.ts +121 -0
  37. package/src/hooks/session-end.ts +467 -0
  38. package/src/hooks/session-start.ts +210 -0
  39. package/src/hooks/user-prompt.ts +91 -0
  40. package/src/import-resolver.ts +224 -0
  41. package/src/memory-db.ts +1376 -0
  42. package/src/memory-tools.ts +391 -0
  43. package/src/middleware-tree.ts +70 -0
  44. package/src/observability-tools.ts +343 -0
  45. package/src/observation-extractor.ts +411 -0
  46. package/src/page-deps.ts +283 -0
  47. package/src/prompt-analyzer.ts +332 -0
  48. package/src/regression-detector.ts +319 -0
  49. package/src/rules.ts +57 -0
  50. package/src/schema-mapper.ts +232 -0
  51. package/src/security-scorer.ts +405 -0
  52. package/src/security-utils.ts +133 -0
  53. package/src/sentinel-db.ts +578 -0
  54. package/src/sentinel-scanner.ts +405 -0
  55. package/src/sentinel-tools.ts +512 -0
  56. package/src/sentinel-types.ts +140 -0
  57. package/src/server.ts +189 -0
  58. package/src/session-archiver.ts +112 -0
  59. package/src/session-state-generator.ts +174 -0
  60. package/src/team-knowledge.ts +407 -0
  61. package/src/tools.ts +847 -0
  62. package/src/transcript-parser.ts +458 -0
  63. package/src/trpc-index.ts +214 -0
  64. package/src/validate-features-runner.ts +106 -0
  65. package/src/validation-engine.ts +358 -0
  66. package/dist/cli.js +0 -7890
  67. package/dist/server.js +0 -7008
@@ -0,0 +1,405 @@
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 { readFileSync, existsSync, readdirSync, statSync } from 'fs';
6
+ import { resolve, join, basename, dirname, relative } from 'path';
7
+ import {
8
+ upsertFeature,
9
+ linkComponent,
10
+ linkProcedure,
11
+ linkPage,
12
+ logChange,
13
+ } from './sentinel-db.ts';
14
+ import type { FeatureInput, FeaturePriority } from './sentinel-types.ts';
15
+ import { getConfig, getProjectRoot } from './config.ts';
16
+
17
+ // ============================================================
18
+ // Sentinel: Feature Auto-Discovery Scanner
19
+ // ============================================================
20
+
21
+ interface DiscoveredFeature extends FeatureInput {
22
+ components: { file: string; name: string | null; role: 'implementation' | 'ui' | 'data' | 'utility'; isPrimary: boolean }[];
23
+ procedures: { router: string; procedure: string; type: string }[];
24
+ pages: { route: string; portal: string | null }[];
25
+ }
26
+
27
+ // ============================================================
28
+ // Domain inference from file path
29
+ // ============================================================
30
+
31
+ function inferDomain(filePath: string): string {
32
+ const domains = getConfig().domains;
33
+ const path = filePath.toLowerCase();
34
+
35
+ // Try to match against configured domain page patterns and router patterns
36
+ for (const domain of domains) {
37
+ const domainLower = domain.name.toLowerCase();
38
+ // Check if any router name appears in the path
39
+ for (const router of domain.routers) {
40
+ const routerLower = router.replace(/\*/g, '').toLowerCase();
41
+ if (routerLower && path.includes(routerLower)) {
42
+ return domainLower;
43
+ }
44
+ }
45
+ // Check if domain name keyword appears in the path
46
+ const nameWords = domainLower.split(/[\/\s]+/);
47
+ for (const word of nameWords) {
48
+ if (word.length > 2 && path.includes('/' + word + '/')) {
49
+ return domainLower;
50
+ }
51
+ }
52
+ }
53
+
54
+ return 'system';
55
+ }
56
+
57
+ function inferSubdomain(routerName: string, procedureName: string): string {
58
+ // Convert camelCase router to kebab-case subdomain
59
+ return routerName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '');
60
+ }
61
+
62
+ function kebabToTitle(kebab: string): string {
63
+ return kebab
64
+ .split(/[-_.]/)
65
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
66
+ .join(' ');
67
+ }
68
+
69
+ // ============================================================
70
+ // @feature annotation parser
71
+ // ============================================================
72
+
73
+ interface FeatureAnnotation {
74
+ featureKey: string;
75
+ title?: string;
76
+ priority?: string;
77
+ description?: string;
78
+ }
79
+
80
+ function parseFeatureAnnotations(source: string): FeatureAnnotation[] {
81
+ const annotations: FeatureAnnotation[] = [];
82
+ const regex = /@feature\s+([\w.-]+)/g;
83
+ const titleRegex = /@feature-title\s+(.+)/g;
84
+ const priorityRegex = /@feature-priority\s+(\w+)/g;
85
+
86
+ let match;
87
+ while ((match = regex.exec(source)) !== null) {
88
+ const annotation: FeatureAnnotation = { featureKey: match[1] };
89
+
90
+ // Look for companion annotations in nearby text
91
+ const contextStart = Math.max(0, match.index - 200);
92
+ const contextEnd = Math.min(source.length, match.index + 300);
93
+ const context = source.substring(contextStart, contextEnd);
94
+
95
+ const titleMatch = /@feature-title\s+(.+)/.exec(context);
96
+ if (titleMatch) annotation.title = titleMatch[1].trim();
97
+
98
+ const priorityMatch = /@feature-priority\s+(\w+)/.exec(context);
99
+ if (priorityMatch) annotation.priority = priorityMatch[1].trim();
100
+
101
+ annotations.push(annotation);
102
+ }
103
+
104
+ return annotations;
105
+ }
106
+
107
+ // ============================================================
108
+ // Scanner: tRPC Procedures -> Features
109
+ // ============================================================
110
+
111
+ function scanTrpcProcedures(dataDb: Database.Database): DiscoveredFeature[] {
112
+ const features: DiscoveredFeature[] = [];
113
+ const featureMap = new Map<string, DiscoveredFeature>();
114
+
115
+ const procedures = dataDb.prepare(`
116
+ SELECT router_name, procedure_name, procedure_type, router_file
117
+ FROM massu_trpc_procedures
118
+ ORDER BY router_name, procedure_name
119
+ `).all() as { router_name: string; procedure_name: string; procedure_type: string; router_file: string }[];
120
+
121
+ for (const proc of procedures) {
122
+ const subdomain = inferSubdomain(proc.router_name, proc.procedure_name);
123
+ const domain = inferDomain(proc.router_file);
124
+
125
+ // Group procedures by router into features
126
+ const featureKey = `${subdomain}.${proc.procedure_name}`;
127
+ const routerFeatureKey = `${subdomain}.crud`;
128
+
129
+ // Individual procedure-level feature
130
+ if (!featureMap.has(featureKey)) {
131
+ featureMap.set(featureKey, {
132
+ feature_key: featureKey,
133
+ domain,
134
+ subdomain,
135
+ title: `${kebabToTitle(subdomain)} - ${kebabToTitle(proc.procedure_name)}`,
136
+ status: 'active',
137
+ priority: 'standard',
138
+ components: [],
139
+ procedures: [],
140
+ pages: [],
141
+ });
142
+ }
143
+
144
+ const feature = featureMap.get(featureKey)!;
145
+ feature.procedures.push({
146
+ router: proc.router_name,
147
+ procedure: proc.procedure_name,
148
+ type: proc.procedure_type,
149
+ });
150
+
151
+ // Link the router file as a component
152
+ if (!feature.components.some(c => c.file === proc.router_file)) {
153
+ feature.components.push({
154
+ file: proc.router_file,
155
+ name: null,
156
+ role: 'data',
157
+ isPrimary: false,
158
+ });
159
+ }
160
+ }
161
+
162
+ return Array.from(featureMap.values());
163
+ }
164
+
165
+ // ============================================================
166
+ // Scanner: Page Routes -> Features
167
+ // ============================================================
168
+
169
+ function scanPageRoutes(dataDb: Database.Database): DiscoveredFeature[] {
170
+ const features: DiscoveredFeature[] = [];
171
+
172
+ const pages = dataDb.prepare(`
173
+ SELECT page_file, route, portal, components, hooks, routers
174
+ FROM massu_page_deps
175
+ ORDER BY route
176
+ `).all() as { page_file: string; route: string; portal: string; components: string; hooks: string; routers: string }[];
177
+
178
+ for (const page of pages) {
179
+ const domain = inferDomain(page.route);
180
+ const routeParts = page.route.split('/').filter(Boolean);
181
+ const subdomain = routeParts.length > 1 ? routeParts.slice(0, 2).join('-') : routeParts[0] || 'root';
182
+
183
+ // Skip layout pages, error pages, etc.
184
+ if (page.route.includes('error') || page.route.includes('not-found') || page.route === '/') continue;
185
+
186
+ const featureKey = `page.${page.route.replace(/\//g, '.').replace(/^\.|\.$/g, '').replace(/\[(\w+)\]/g, '_$1_')}`;
187
+
188
+ const components = JSON.parse(page.components || '[]') as string[];
189
+ const routers = JSON.parse(page.routers || '[]') as string[];
190
+
191
+ const feature: DiscoveredFeature = {
192
+ feature_key: featureKey,
193
+ domain,
194
+ subdomain: subdomain.replace(/\//g, '-'),
195
+ title: `Page: ${page.route}`,
196
+ status: 'active',
197
+ priority: 'standard',
198
+ portal_scope: [page.portal],
199
+ components: [
200
+ { file: page.page_file, name: null, role: 'ui', isPrimary: true },
201
+ ...components.map(c => ({ file: c, name: null as string | null, role: 'ui' as const, isPrimary: false })),
202
+ ],
203
+ procedures: [],
204
+ pages: [{ route: page.route, portal: page.portal }],
205
+ };
206
+
207
+ features.push(feature);
208
+ }
209
+
210
+ return features;
211
+ }
212
+
213
+ // ============================================================
214
+ // Scanner: Component Exports -> Features
215
+ // ============================================================
216
+
217
+ function scanComponentExports(dataDb: Database.Database): DiscoveredFeature[] {
218
+ const features: DiscoveredFeature[] = [];
219
+
220
+ // Scan component directories for interactive features
221
+ const config = getConfig();
222
+ const projectRoot = getProjectRoot();
223
+ const componentsBase = config.paths.components ?? (config.paths.source + '/components');
224
+
225
+ // Build component dirs from domain names + a generic scan
226
+ const componentDirs: string[] = [];
227
+ const basePath = resolve(projectRoot, componentsBase);
228
+ if (existsSync(basePath)) {
229
+ try {
230
+ const entries = readdirSync(basePath, { withFileTypes: true });
231
+ for (const entry of entries) {
232
+ if (entry.isDirectory()) {
233
+ componentDirs.push(componentsBase + '/' + entry.name);
234
+ }
235
+ }
236
+ } catch {
237
+ // Skip if unreadable
238
+ }
239
+ }
240
+
241
+ for (const dir of componentDirs) {
242
+ const absDir = resolve(projectRoot, dir);
243
+ if (!existsSync(absDir)) continue;
244
+
245
+ const files = walkDir(absDir).filter(f => f.endsWith('.tsx') || f.endsWith('.ts'));
246
+ for (const file of files) {
247
+ const relPath = relative(projectRoot, file);
248
+ const source = readFileSync(file, 'utf-8');
249
+
250
+ // Parse @feature annotations
251
+ const annotations = parseFeatureAnnotations(source);
252
+ if (annotations.length > 0) {
253
+ for (const ann of annotations) {
254
+ const domain = inferDomain(relPath);
255
+ const parts = ann.featureKey.split('.');
256
+ const feature: DiscoveredFeature = {
257
+ feature_key: ann.featureKey,
258
+ domain,
259
+ subdomain: parts[0],
260
+ title: ann.title || kebabToTitle(ann.featureKey),
261
+ description: ann.description,
262
+ status: 'active',
263
+ priority: (ann.priority as FeaturePriority) || 'standard',
264
+ components: [{ file: relPath, name: null, role: 'implementation', isPrimary: true }],
265
+ procedures: [],
266
+ pages: [],
267
+ };
268
+ features.push(feature);
269
+ }
270
+ }
271
+
272
+ // Detect interactive features: exported functions with mutation/handler patterns
273
+ const hasHandlers = /onClick|onSubmit|useMutation|api\.\w+\.\w+\.use/.test(source);
274
+ const exportMatch = /export\s+(?:default\s+)?(?:function|const)\s+(\w+)/.exec(source);
275
+
276
+ if (hasHandlers && exportMatch) {
277
+ const componentName = exportMatch[1];
278
+ const domain = inferDomain(relPath);
279
+ const subdomain = basename(dirname(relPath));
280
+ const featureKey = `component.${subdomain}.${componentName.replace(/([A-Z])/g, '-$1').toLowerCase().replace(/^-/, '')}`;
281
+
282
+ // Only add if not already covered by annotation
283
+ if (!annotations.some(a => a.featureKey === featureKey)) {
284
+ features.push({
285
+ feature_key: featureKey,
286
+ domain,
287
+ subdomain,
288
+ title: `${componentName}`,
289
+ status: 'active',
290
+ priority: 'standard',
291
+ components: [{ file: relPath, name: componentName, role: 'implementation', isPrimary: true }],
292
+ procedures: [],
293
+ pages: [],
294
+ });
295
+ }
296
+ }
297
+ }
298
+ }
299
+
300
+ return features;
301
+ }
302
+
303
+ // ============================================================
304
+ // Utility: Walk directory recursively
305
+ // ============================================================
306
+
307
+ function walkDir(dir: string): string[] {
308
+ const results: string[] = [];
309
+ try {
310
+ const entries = readdirSync(dir);
311
+ for (const entry of entries) {
312
+ const fullPath = join(dir, entry);
313
+ try {
314
+ const stat = statSync(fullPath);
315
+ if (stat.isDirectory()) {
316
+ results.push(...walkDir(fullPath));
317
+ } else {
318
+ results.push(fullPath);
319
+ }
320
+ } catch {
321
+ // Skip unreadable entries
322
+ }
323
+ }
324
+ } catch {
325
+ // Skip unreadable directories
326
+ }
327
+ return results;
328
+ }
329
+
330
+ // ============================================================
331
+ // Main Scanner Entry Point
332
+ // ============================================================
333
+
334
+ export interface ScanResult {
335
+ totalDiscovered: number;
336
+ fromProcedures: number;
337
+ fromPages: number;
338
+ fromComponents: number;
339
+ registered: number;
340
+ }
341
+
342
+ export function runFeatureScan(dataDb: Database.Database): ScanResult {
343
+ const procedureFeatures = scanTrpcProcedures(dataDb);
344
+ const pageFeatures = scanPageRoutes(dataDb);
345
+ const componentFeatures = scanComponentExports(dataDb);
346
+
347
+ // Merge: annotations take priority, then components, then pages, then procedures
348
+ const allFeatures = new Map<string, DiscoveredFeature>();
349
+
350
+ // Add procedures first (lowest priority)
351
+ for (const f of procedureFeatures) {
352
+ allFeatures.set(f.feature_key, f);
353
+ }
354
+
355
+ // Add pages
356
+ for (const f of pageFeatures) {
357
+ allFeatures.set(f.feature_key, f);
358
+ }
359
+
360
+ // Add components (highest priority from scanning)
361
+ for (const f of componentFeatures) {
362
+ allFeatures.set(f.feature_key, f);
363
+ }
364
+
365
+ // Register all discovered features
366
+ let registered = 0;
367
+ for (const feature of allFeatures.values()) {
368
+ const featureId = upsertFeature(dataDb, {
369
+ feature_key: feature.feature_key,
370
+ domain: feature.domain,
371
+ subdomain: feature.subdomain,
372
+ title: feature.title,
373
+ description: feature.description,
374
+ status: feature.status,
375
+ priority: feature.priority,
376
+ portal_scope: feature.portal_scope,
377
+ });
378
+
379
+ // Link components
380
+ for (const comp of feature.components) {
381
+ linkComponent(dataDb, featureId, comp.file, comp.name, comp.role, comp.isPrimary);
382
+ }
383
+
384
+ // Link procedures
385
+ for (const proc of feature.procedures) {
386
+ linkProcedure(dataDb, featureId, proc.router, proc.procedure, proc.type);
387
+ }
388
+
389
+ // Link pages
390
+ for (const page of feature.pages) {
391
+ linkPage(dataDb, featureId, page.route, page.portal ?? undefined);
392
+ }
393
+
394
+ logChange(dataDb, featureId, 'created', 'Auto-discovered by sentinel scanner', undefined, 'scanner');
395
+ registered++;
396
+ }
397
+
398
+ return {
399
+ totalDiscovered: allFeatures.size,
400
+ fromProcedures: procedureFeatures.length,
401
+ fromPages: pageFeatures.length,
402
+ fromComponents: componentFeatures.length,
403
+ registered,
404
+ };
405
+ }