@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,407 @@
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 './tools.ts';
6
+ import { getConfig } from './config.ts';
7
+
8
+ // ============================================================
9
+ // Team Knowledge Graph
10
+ // ============================================================
11
+
12
+ /** Prefix a base tool name with the configured tool prefix. */
13
+ function p(baseName: string): string {
14
+ return `${getConfig().toolPrefix}_${baseName}`;
15
+ }
16
+
17
+ /**
18
+ * Calculate expertise score for a developer in a module.
19
+ * Based on session depth (how many sessions) and observation quality.
20
+ */
21
+ export function calculateExpertise(
22
+ sessionCount: number,
23
+ observationCount: number
24
+ ): number {
25
+ const config = getConfig();
26
+ const sessionWeight = config.team?.expertise_weights?.session ?? 20;
27
+ const observationWeight = config.team?.expertise_weights?.observation ?? 10;
28
+
29
+ const sessionScore = Math.log2(sessionCount + 1) * sessionWeight;
30
+ const obsScore = Math.log2(observationCount + 1) * observationWeight;
31
+ return Math.min(100, Math.round(sessionScore + obsScore));
32
+ }
33
+
34
+ /**
35
+ * Update developer expertise based on session observations.
36
+ */
37
+ export function updateExpertise(
38
+ db: Database.Database,
39
+ developerId: string,
40
+ sessionId: string
41
+ ): void {
42
+ const fileChanges = db.prepare(`
43
+ SELECT DISTINCT files_involved FROM observations
44
+ WHERE session_id = ? AND type IN ('file_change', 'feature', 'bugfix', 'refactor')
45
+ `).all(sessionId) as Array<{ files_involved: string }>;
46
+
47
+ const modules = new Set<string>();
48
+ for (const fc of fileChanges) {
49
+ try {
50
+ const files = JSON.parse(fc.files_involved) as string[];
51
+ for (const file of files) {
52
+ const module = extractModule(file);
53
+ if (module) modules.add(module);
54
+ }
55
+ } catch { /* skip */ }
56
+ }
57
+
58
+ for (const module of modules) {
59
+ const existing = db.prepare(
60
+ 'SELECT session_count, observation_count FROM developer_expertise WHERE developer_id = ? AND module = ?'
61
+ ).get(developerId, module) as { session_count: number; observation_count: number } | undefined;
62
+
63
+ const sessionCount = (existing?.session_count ?? 0) + 1;
64
+ const obsCount = (existing?.observation_count ?? 0) + fileChanges.length;
65
+ const score = calculateExpertise(sessionCount, obsCount);
66
+
67
+ db.prepare(`
68
+ INSERT INTO developer_expertise (developer_id, module, session_count, observation_count, expertise_score, last_active)
69
+ VALUES (?, ?, ?, ?, ?, datetime('now'))
70
+ ON CONFLICT(developer_id, module) DO UPDATE SET
71
+ session_count = ?,
72
+ observation_count = ?,
73
+ expertise_score = ?,
74
+ last_active = datetime('now')
75
+ `).run(
76
+ developerId, module, sessionCount, obsCount, score,
77
+ sessionCount, obsCount, score
78
+ );
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Detect potential conflicts between developers working on same files.
84
+ */
85
+ export function detectConflicts(
86
+ db: Database.Database,
87
+ daysBack: number = 7
88
+ ): Array<{
89
+ filePath: string;
90
+ developerA: string;
91
+ developerB: string;
92
+ conflictType: string;
93
+ }> {
94
+ const conflicts = db.prepare(`
95
+ SELECT so1.file_path,
96
+ so1.developer_id as developer_a,
97
+ so2.developer_id as developer_b,
98
+ 'concurrent_edit' as conflict_type
99
+ FROM shared_observations so1
100
+ JOIN shared_observations so2 ON so1.file_path = so2.file_path
101
+ WHERE so1.developer_id != so2.developer_id
102
+ AND so1.file_path IS NOT NULL
103
+ AND so1.created_at >= datetime('now', ?)
104
+ AND so2.created_at >= datetime('now', ?)
105
+ GROUP BY so1.file_path, so1.developer_id, so2.developer_id
106
+ `).all(`-${daysBack} days`, `-${daysBack} days`) as Array<{
107
+ file_path: string;
108
+ developer_a: string;
109
+ developer_b: string;
110
+ conflict_type: string;
111
+ }>;
112
+
113
+ return conflicts.map(c => ({
114
+ filePath: c.file_path,
115
+ developerA: c.developer_a,
116
+ developerB: c.developer_b,
117
+ conflictType: c.conflict_type,
118
+ }));
119
+ }
120
+
121
+ /**
122
+ * Share an observation for team visibility.
123
+ */
124
+ export function shareObservation(
125
+ db: Database.Database,
126
+ developerId: string,
127
+ project: string,
128
+ observationType: string,
129
+ summary: string,
130
+ opts?: {
131
+ originalId?: number;
132
+ filePath?: string;
133
+ module?: string;
134
+ severity?: number;
135
+ }
136
+ ): number {
137
+ const result = db.prepare(`
138
+ INSERT INTO shared_observations
139
+ (original_id, developer_id, project, observation_type, summary, file_path, module, severity, is_shared, shared_at)
140
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, TRUE, datetime('now'))
141
+ `).run(
142
+ opts?.originalId ?? null,
143
+ developerId, project, observationType, summary,
144
+ opts?.filePath ?? null,
145
+ opts?.module ?? null,
146
+ opts?.severity ?? 3
147
+ );
148
+ return Number(result.lastInsertRowid);
149
+ }
150
+
151
+ /**
152
+ * Extract business module from a file path.
153
+ * Uses configurable module extraction patterns if provided.
154
+ */
155
+ function extractModule(filePath: string): string | null {
156
+ // Route-based modules
157
+ const routerMatch = filePath.match(/routers\/([^/.]+)/);
158
+ if (routerMatch) return routerMatch[1];
159
+
160
+ // Page-based modules
161
+ const pageMatch = filePath.match(/app\/\(([^)]+)\)/);
162
+ if (pageMatch) return pageMatch[1];
163
+
164
+ // Component-based
165
+ const compMatch = filePath.match(/components\/([^/.]+)/);
166
+ if (compMatch) return compMatch[1];
167
+
168
+ return null;
169
+ }
170
+
171
+ // ============================================================
172
+ // MCP Tool Definitions & Handlers
173
+ // ============================================================
174
+
175
+ export function getTeamToolDefinitions(): ToolDefinition[] {
176
+ return [
177
+ {
178
+ name: p('team_search'),
179
+ description: 'Search team-shared observations. Find what other developers learned about a module or file.',
180
+ inputSchema: {
181
+ type: 'object',
182
+ properties: {
183
+ query: { type: 'string', description: 'Search text' },
184
+ module: { type: 'string', description: 'Filter by business module' },
185
+ },
186
+ required: ['query'],
187
+ },
188
+ },
189
+ {
190
+ name: p('team_expertise'),
191
+ description: 'Who knows what. Shows developers ranked by expertise for a module or file area.',
192
+ inputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ module: { type: 'string', description: 'Business module (e.g., orders, products, design)' },
196
+ file_path: { type: 'string', description: 'File path to find experts for' },
197
+ },
198
+ required: [],
199
+ },
200
+ },
201
+ {
202
+ name: p('team_conflicts'),
203
+ description: 'Detect concurrent work conflicts. Find areas where multiple developers are making changes.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ file_path: { type: 'string', description: 'Check specific file for conflicts' },
208
+ days: { type: 'number', description: 'Days to look back (default: 7)' },
209
+ },
210
+ required: [],
211
+ },
212
+ },
213
+ ];
214
+ }
215
+
216
+ const TEAM_BASE_NAMES = new Set(['team_search', 'team_expertise', 'team_conflicts']);
217
+
218
+ export function isTeamTool(name: string): boolean {
219
+ const pfx = getConfig().toolPrefix + '_';
220
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
221
+ return TEAM_BASE_NAMES.has(baseName);
222
+ }
223
+
224
+ export function handleTeamToolCall(
225
+ name: string,
226
+ args: Record<string, unknown>,
227
+ memoryDb: Database.Database
228
+ ): ToolResult {
229
+ try {
230
+ const pfx = getConfig().toolPrefix + '_';
231
+ const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
232
+
233
+ switch (baseName) {
234
+ case 'team_search':
235
+ return handleTeamSearch(args, memoryDb);
236
+ case 'team_expertise':
237
+ return handleTeamExpertise(args, memoryDb);
238
+ case 'team_conflicts':
239
+ return handleTeamConflicts(args, memoryDb);
240
+ default:
241
+ return text(`Unknown team tool: ${name}`);
242
+ }
243
+ } catch (error) {
244
+ return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}\n\nUsage: ${p('team_search')} { query: "pattern" }, ${p('team_expertise')} { module: "tasks" }`);
245
+ }
246
+ }
247
+
248
+ function handleTeamSearch(args: Record<string, unknown>, db: Database.Database): ToolResult {
249
+ const query = args.query as string;
250
+ if (!query) return text(`Usage: ${p('team_search')} { query: "search term", module: "optional-module" } - Search team-shared observations.`);
251
+
252
+ const module = args.module as string | undefined;
253
+
254
+ let sql = `
255
+ SELECT id, developer_id, observation_type, summary, file_path, module, severity, created_at
256
+ FROM shared_observations
257
+ WHERE is_shared = TRUE AND summary LIKE ?
258
+ `;
259
+ const params: (string | number)[] = [`%${query}%`];
260
+
261
+ if (module) {
262
+ sql += ' AND module = ?';
263
+ params.push(module);
264
+ }
265
+
266
+ sql += ' ORDER BY created_at DESC LIMIT 20';
267
+
268
+ const results = db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
269
+
270
+ if (results.length === 0) {
271
+ return text(`No shared observations found for "${query}". Team knowledge is populated when developers share observations across sessions. Try: ${p('team_expertise')} {} to see module expertise, or broaden your search term.`);
272
+ }
273
+
274
+ const lines = [
275
+ `## Team Knowledge: "${query}" (${results.length} results)`,
276
+ '',
277
+ '| Developer | Type | Summary | Module | Date |',
278
+ '|-----------|------|---------|--------|------|',
279
+ ];
280
+
281
+ for (const r of results) {
282
+ lines.push(
283
+ `| ${r.developer_id} | ${r.observation_type} | ${(r.summary as string).slice(0, 60)} | ${r.module ?? '-'} | ${(r.created_at as string).split('T')[0]} |`
284
+ );
285
+ }
286
+
287
+ return text(lines.join('\n'));
288
+ }
289
+
290
+ function handleTeamExpertise(args: Record<string, unknown>, db: Database.Database): ToolResult {
291
+ const module = args.module as string | undefined;
292
+ const filePath = args.file_path as string | undefined;
293
+
294
+ let targetModule = module;
295
+ if (!targetModule && filePath) {
296
+ targetModule = extractModule(filePath) ?? undefined;
297
+ }
298
+
299
+ if (!targetModule) {
300
+ const modules = db.prepare(`
301
+ SELECT module, COUNT(DISTINCT developer_id) as developers, MAX(expertise_score) as top_score
302
+ FROM developer_expertise
303
+ GROUP BY module
304
+ ORDER BY developers DESC
305
+ `).all() as Array<Record<string, unknown>>;
306
+
307
+ if (modules.length === 0) {
308
+ return text(`No expertise data yet. Expertise is built automatically as developers work on modules across sessions. Try: ${p('team_search')} { query: "keyword" } to search shared observations instead.`);
309
+ }
310
+
311
+ const lines = [
312
+ '## Team Expertise Overview',
313
+ '',
314
+ '| Module | Developers | Top Score |',
315
+ '|--------|-----------|-----------|',
316
+ ];
317
+
318
+ for (const m of modules) {
319
+ lines.push(`| ${m.module} | ${m.developers} | ${m.top_score} |`);
320
+ }
321
+
322
+ lines.push('');
323
+ lines.push(`Use ${p('team_expertise')} { module: "module_name" } to see developers ranked by expertise.`);
324
+
325
+ return text(lines.join('\n'));
326
+ }
327
+
328
+ const experts = db.prepare(`
329
+ SELECT developer_id, expertise_score, session_count, observation_count, last_active
330
+ FROM developer_expertise
331
+ WHERE module = ?
332
+ ORDER BY expertise_score DESC
333
+ `).all(targetModule) as Array<Record<string, unknown>>;
334
+
335
+ if (experts.length === 0) {
336
+ return text(`No expertise data for module "${targetModule}". Expertise builds as developers work on files in this module across sessions. Try: ${p('team_expertise')} {} to see all modules with tracked expertise.`);
337
+ }
338
+
339
+ const lines = [
340
+ `## Expertise: ${targetModule}`,
341
+ '',
342
+ '| Developer | Score | Sessions | Observations | Last Active |',
343
+ '|-----------|-------|----------|--------------|-------------|',
344
+ ];
345
+
346
+ for (const e of experts) {
347
+ lines.push(
348
+ `| ${e.developer_id} | ${e.expertise_score} | ${e.session_count} | ${e.observation_count} | ${(e.last_active as string).split('T')[0]} |`
349
+ );
350
+ }
351
+
352
+ return text(lines.join('\n'));
353
+ }
354
+
355
+ function handleTeamConflicts(args: Record<string, unknown>, db: Database.Database): ToolResult {
356
+ const days = (args.days as number) ?? 7;
357
+ const filePath = args.file_path as string | undefined;
358
+
359
+ if (filePath) {
360
+ const conflicts = db.prepare(`
361
+ SELECT developer_a, developer_b, conflict_type, detected_at, resolved
362
+ FROM knowledge_conflicts
363
+ WHERE file_path = ?
364
+ ORDER BY detected_at DESC LIMIT 10
365
+ `).all(filePath) as Array<Record<string, unknown>>;
366
+
367
+ if (conflicts.length === 0) {
368
+ return text(`No conflicts detected for "${filePath}". Conflicts are detected when multiple developers modify the same file within the lookback window. Try: ${p('team_conflicts')} { days: 30 } to check for conflicts across all files.`);
369
+ }
370
+
371
+ const lines = [
372
+ `## Conflicts: ${filePath}`,
373
+ '',
374
+ ];
375
+
376
+ for (const c of conflicts) {
377
+ lines.push(`- ${c.developer_a} vs ${c.developer_b} (${c.conflict_type}) - ${c.resolved ? 'resolved' : 'ACTIVE'}`);
378
+ }
379
+
380
+ return text(lines.join('\n'));
381
+ }
382
+
383
+ // General conflict detection
384
+ const conflicts = detectConflicts(db, days);
385
+
386
+ if (conflicts.length === 0) {
387
+ return text(`No concurrent work conflicts detected in the last ${days} days. Conflicts are tracked when multiple developers modify the same files. Try a longer time range: ${p('team_conflicts')} { days: 90 }.`);
388
+ }
389
+
390
+ const lines = [
391
+ `## Work Conflicts (${days} days)`,
392
+ `Detected: ${conflicts.length}`,
393
+ '',
394
+ '| File | Developer A | Developer B | Type |',
395
+ '|------|-----------|-----------|------|',
396
+ ];
397
+
398
+ for (const c of conflicts) {
399
+ lines.push(`| ${c.filePath} | ${c.developerA} | ${c.developerB} | ${c.conflictType} |`);
400
+ }
401
+
402
+ return text(lines.join('\n'));
403
+ }
404
+
405
+ function text(content: string): ToolResult {
406
+ return { content: [{ type: 'text', text: content }] };
407
+ }