@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,391 @@
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 {
7
+ searchObservations,
8
+ getRecentObservations,
9
+ getSessionSummaries,
10
+ getSessionTimeline,
11
+ getFailedAttempts,
12
+ addObservation,
13
+ assignImportance,
14
+ createSession,
15
+ } from './memory-db.ts';
16
+ import { getConfig } from './config.ts';
17
+
18
+ /** Prefix a base tool name with the configured tool prefix. */
19
+ function p(baseName: string): string {
20
+ return `${getConfig().toolPrefix}_${baseName}`;
21
+ }
22
+
23
+ // ============================================================
24
+ // P4-001 through P4-006: MCP Memory Tools
25
+ // ============================================================
26
+
27
+ /**
28
+ * Get all memory tool definitions.
29
+ */
30
+ export function getMemoryToolDefinitions(): ToolDefinition[] {
31
+ return [
32
+ // P4-001: memory_search
33
+ {
34
+ name: p('memory_search'),
35
+ description: 'Search past session observations and decisions using full-text search. Returns compact index of matching observations.',
36
+ inputSchema: {
37
+ type: 'object',
38
+ properties: {
39
+ query: { type: 'string', description: 'Search text (FTS5 query syntax supported)' },
40
+ type: { type: 'string', description: 'Filter by observation type (decision, bugfix, feature, failed_attempt, cr_violation, vr_check, etc.)' },
41
+ cr_rule: { type: 'string', description: 'Filter by CR rule (e.g., CR-9)' },
42
+ date_from: { type: 'string', description: 'Start date (ISO format)' },
43
+ limit: { type: 'number', description: 'Max results (default: 20)' },
44
+ },
45
+ required: ['query'],
46
+ },
47
+ },
48
+ // P4-002: memory_timeline
49
+ {
50
+ name: p('memory_timeline'),
51
+ description: 'Retrieve episodic memory - chronological context around a specific event. Shows observations before and after the anchor point to reconstruct what happened and why.',
52
+ inputSchema: {
53
+ type: 'object',
54
+ properties: {
55
+ observation_id: { type: 'number', description: 'Anchor observation ID' },
56
+ depth_before: { type: 'number', description: 'Items before (default: 5)' },
57
+ depth_after: { type: 'number', description: 'Items after (default: 5)' },
58
+ },
59
+ required: ['observation_id'],
60
+ },
61
+ },
62
+ // P4-003: memory_detail
63
+ {
64
+ name: p('memory_detail'),
65
+ description: 'Get full observation details by IDs (batch). Includes evidence, files, plan items.',
66
+ inputSchema: {
67
+ type: 'object',
68
+ properties: {
69
+ ids: {
70
+ type: 'array',
71
+ items: { type: 'number' },
72
+ description: 'Observation IDs to retrieve',
73
+ },
74
+ },
75
+ required: ['ids'],
76
+ },
77
+ },
78
+ // P4-004: memory_sessions
79
+ {
80
+ name: p('memory_sessions'),
81
+ description: 'List recent sessions with summaries.',
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ limit: { type: 'number', description: 'Max sessions (default: 10)' },
86
+ status: { type: 'string', description: 'Filter by status (active, completed, abandoned)' },
87
+ },
88
+ required: [],
89
+ },
90
+ },
91
+ // P4-005: memory_failures
92
+ {
93
+ name: p('memory_failures'),
94
+ description: 'Get all failed attempts (DON\'T RETRY warnings). Check before attempting a fix to see if it was already tried.',
95
+ inputSchema: {
96
+ type: 'object',
97
+ properties: {
98
+ query: { type: 'string', description: 'Filter by keyword' },
99
+ limit: { type: 'number', description: 'Max results (default: 20)' },
100
+ },
101
+ required: [],
102
+ },
103
+ },
104
+ // P4-006: memory_ingest
105
+ {
106
+ name: p('memory_ingest'),
107
+ description: 'Manually record an observation that hooks cannot auto-detect. Use for significant decisions, discoveries, or failed attempts mid-session.',
108
+ inputSchema: {
109
+ type: 'object',
110
+ properties: {
111
+ type: {
112
+ type: 'string',
113
+ description: 'Observation type: decision, bugfix, feature, refactor, discovery, cr_violation, vr_check, pattern_compliance, failed_attempt, file_change, incident_near_miss',
114
+ },
115
+ title: { type: 'string', description: 'Short description' },
116
+ detail: { type: 'string', description: 'Full context' },
117
+ importance: { type: 'number', description: 'Override importance (1-5, default: auto-assigned)' },
118
+ cr_rule: { type: 'string', description: 'Link to CR rule (e.g., CR-9)' },
119
+ plan_item: { type: 'string', description: 'Link to plan item (e.g., P2-003)' },
120
+ files: {
121
+ type: 'array',
122
+ items: { type: 'string' },
123
+ description: 'Files involved',
124
+ },
125
+ },
126
+ required: ['type', 'title'],
127
+ },
128
+ },
129
+ ];
130
+ }
131
+
132
+ /**
133
+ * Handle a memory tool call.
134
+ */
135
+ export function handleMemoryToolCall(
136
+ name: string,
137
+ args: Record<string, unknown>,
138
+ memoryDb: Database.Database
139
+ ): ToolResult {
140
+ try {
141
+ const prefix = getConfig().toolPrefix + '_';
142
+ const baseName = name.startsWith(prefix) ? name.slice(prefix.length) : name;
143
+
144
+ switch (baseName) {
145
+ case 'memory_search':
146
+ return handleSearch(args, memoryDb);
147
+ case 'memory_timeline':
148
+ return handleTimeline(args, memoryDb);
149
+ case 'memory_detail':
150
+ return handleDetail(args, memoryDb);
151
+ case 'memory_sessions':
152
+ return handleSessions(args, memoryDb);
153
+ case 'memory_failures':
154
+ return handleFailures(args, memoryDb);
155
+ case 'memory_ingest':
156
+ return handleIngest(args, memoryDb);
157
+ default:
158
+ return text(`Unknown memory tool: ${name}`);
159
+ }
160
+ } catch (error) {
161
+ return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}`);
162
+ }
163
+ }
164
+
165
+ // ============================================================
166
+ // Tool Handlers
167
+ // ============================================================
168
+
169
+ function handleSearch(args: Record<string, unknown>, db: Database.Database): ToolResult {
170
+ const query = args.query as string;
171
+ if (!query) return text('Error: query is required');
172
+
173
+ const results = searchObservations(db, query, {
174
+ type: args.type as string | undefined,
175
+ crRule: args.cr_rule as string | undefined,
176
+ dateFrom: args.date_from as string | undefined,
177
+ limit: args.limit as number | undefined,
178
+ });
179
+
180
+ if (results.length === 0) {
181
+ return text(`No observations found for "${query}".`);
182
+ }
183
+
184
+ const lines = [`## Search Results for "${query}" (${results.length} matches)`, ''];
185
+ lines.push('| ID | Type | Title | Date | Importance |');
186
+ lines.push('|----|------|-------|------|------------|');
187
+
188
+ for (const r of results) {
189
+ lines.push(`| ${r.id} | ${r.type} | ${r.title.slice(0, 80)} | ${r.created_at.split('T')[0]} | ${r.importance} |`);
190
+ }
191
+
192
+ lines.push('');
193
+ lines.push(`Use ${p('memory_detail')} with IDs for full details.`);
194
+ lines.push(`Use ${p('memory_timeline')} with an ID for chronological context.`);
195
+
196
+ return text(lines.join('\n'));
197
+ }
198
+
199
+ function handleTimeline(args: Record<string, unknown>, db: Database.Database): ToolResult {
200
+ const observationId = args.observation_id as number;
201
+ if (!observationId) return text('Error: observation_id is required');
202
+
203
+ const depthBefore = (args.depth_before as number) ?? 5;
204
+ const depthAfter = (args.depth_after as number) ?? 5;
205
+
206
+ // Get the anchor observation
207
+ const anchor = db.prepare('SELECT * FROM observations WHERE id = ?').get(observationId) as Record<string, unknown> | undefined;
208
+ if (!anchor) return text(`Observation ${observationId} not found.`);
209
+
210
+ // Get observations before
211
+ const before = db.prepare(
212
+ 'SELECT id, type, title, created_at, importance FROM observations WHERE session_id = ? AND created_at_epoch < ? ORDER BY created_at_epoch DESC LIMIT ?'
213
+ ).all(anchor.session_id, anchor.created_at_epoch, depthBefore) as Array<Record<string, unknown>>;
214
+
215
+ // Get observations after
216
+ const after = db.prepare(
217
+ 'SELECT id, type, title, created_at, importance FROM observations WHERE session_id = ? AND created_at_epoch > ? ORDER BY created_at_epoch ASC LIMIT ?'
218
+ ).all(anchor.session_id, anchor.created_at_epoch, depthAfter) as Array<Record<string, unknown>>;
219
+
220
+ const lines = [`## Timeline around observation #${observationId}`, ''];
221
+
222
+ // Before (reversed to chronological order)
223
+ for (const o of before.reverse()) {
224
+ lines.push(` ${o.id} | ${o.type} | ${(o.title as string).slice(0, 60)} | ${(o.created_at as string).split('T')[0]}`);
225
+ }
226
+
227
+ // Anchor
228
+ lines.push(`> ${anchor.id} | ${anchor.type} | ${(anchor.title as string).slice(0, 60)} | ${(anchor.created_at as string).split('T')[0]} <-- ANCHOR`);
229
+
230
+ // After
231
+ for (const o of after) {
232
+ lines.push(` ${o.id} | ${o.type} | ${(o.title as string).slice(0, 60)} | ${(o.created_at as string).split('T')[0]}`);
233
+ }
234
+
235
+ return text(lines.join('\n'));
236
+ }
237
+
238
+ function handleDetail(args: Record<string, unknown>, db: Database.Database): ToolResult {
239
+ const ids = args.ids as number[];
240
+ if (!ids || ids.length === 0) return text('Error: ids array is required');
241
+
242
+ const placeholders = ids.map(() => '?').join(',');
243
+ const observations = db.prepare(
244
+ `SELECT * FROM observations WHERE id IN (${placeholders}) ORDER BY created_at_epoch ASC`
245
+ ).all(...ids) as Array<Record<string, unknown>>;
246
+
247
+ if (observations.length === 0) {
248
+ return text('No observations found for the given IDs.');
249
+ }
250
+
251
+ const lines: string[] = [];
252
+ for (const o of observations) {
253
+ lines.push(`## Observation #${o.id} [${o.type}] (importance: ${o.importance})`);
254
+ lines.push(`**Title**: ${o.title}`);
255
+ lines.push(`**Session**: ${o.session_id}`);
256
+ lines.push(`**Date**: ${o.created_at}`);
257
+
258
+ if (o.detail) lines.push(`**Detail**: ${o.detail}`);
259
+ if (o.files_involved && o.files_involved !== '[]') {
260
+ const files = safeParseJson(o.files_involved as string, []) as string[];
261
+ if (files.length > 0) lines.push(`**Files**: ${files.join(', ')}`);
262
+ }
263
+ if (o.plan_item) lines.push(`**Plan Item**: ${o.plan_item}`);
264
+ if (o.cr_rule) lines.push(`**CR Rule**: ${o.cr_rule}`);
265
+ if (o.vr_type) lines.push(`**VR Type**: ${o.vr_type}`);
266
+ if (o.evidence) lines.push(`**Evidence**: ${(o.evidence as string).slice(0, 500)}`);
267
+ if ((o.recurrence_count as number) > 1) lines.push(`**Recurrence**: ${o.recurrence_count}x`);
268
+ lines.push('');
269
+ }
270
+
271
+ return text(lines.join('\n'));
272
+ }
273
+
274
+ function handleSessions(args: Record<string, unknown>, db: Database.Database): ToolResult {
275
+ const limit = (args.limit as number) ?? 10;
276
+ const status = args.status as string | undefined;
277
+
278
+ let sql = 'SELECT * FROM sessions';
279
+ const params: (string | number)[] = [];
280
+
281
+ if (status) {
282
+ sql += ' WHERE status = ?';
283
+ params.push(status);
284
+ }
285
+
286
+ sql += ' ORDER BY started_at_epoch DESC LIMIT ?';
287
+ params.push(limit);
288
+
289
+ const sessions = db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
290
+
291
+ if (sessions.length === 0) {
292
+ return text('No sessions found.');
293
+ }
294
+
295
+ const lines = ['## Recent Sessions', ''];
296
+ lines.push('| Session ID | Status | Branch | Started | Plan |');
297
+ lines.push('|------------|--------|--------|---------|------|');
298
+
299
+ for (const s of sessions) {
300
+ const started = (s.started_at as string).split('T')[0];
301
+ const plan = s.plan_file ? (s.plan_file as string).split('/').pop() : '-';
302
+ lines.push(`| ${(s.session_id as string).slice(0, 8)}... | ${s.status} | ${s.git_branch ?? '-'} | ${started} | ${plan} |`);
303
+ }
304
+
305
+ // Add summaries for each session
306
+ for (const s of sessions) {
307
+ const summaries = getSessionSummaries(db, 1);
308
+ const summary = summaries.find(sm => sm.session_id === s.session_id);
309
+ if (summary) {
310
+ lines.push('');
311
+ lines.push(`### ${(s.session_id as string).slice(0, 8)}...`);
312
+ if (summary.request) lines.push(`**Task**: ${summary.request.slice(0, 200)}`);
313
+ if (summary.completed) lines.push(`**Completed**: ${summary.completed.slice(0, 200)}`);
314
+ }
315
+ }
316
+
317
+ return text(lines.join('\n'));
318
+ }
319
+
320
+ function handleFailures(args: Record<string, unknown>, db: Database.Database): ToolResult {
321
+ const query = args.query as string | undefined;
322
+ const limit = (args.limit as number) ?? 20;
323
+
324
+ const failures = getFailedAttempts(db, query, limit);
325
+
326
+ if (failures.length === 0) {
327
+ return text(query ? `No failed attempts found for "${query}".` : 'No failed attempts recorded.');
328
+ }
329
+
330
+ const lines = ['## Failed Attempts (DO NOT RETRY)', ''];
331
+
332
+ for (const f of failures) {
333
+ const recurrence = f.recurrence_count > 1 ? ` (occurred ${f.recurrence_count}x across sessions)` : '';
334
+ lines.push(`### #${f.id}: ${f.title}${recurrence}`);
335
+ if (f.detail) lines.push(f.detail.slice(0, 500));
336
+ lines.push(`Session: ${f.session_id.slice(0, 8)}... | Date: ${f.created_at.split('T')[0]}`);
337
+ lines.push('');
338
+ }
339
+
340
+ return text(lines.join('\n'));
341
+ }
342
+
343
+ function handleIngest(args: Record<string, unknown>, db: Database.Database): ToolResult {
344
+ const type = args.type as string;
345
+ const title = args.title as string;
346
+
347
+ if (!type || !title) return text('Error: type and title are required');
348
+
349
+ const validTypes = ['decision', 'bugfix', 'feature', 'refactor', 'discovery',
350
+ 'cr_violation', 'vr_check', 'pattern_compliance', 'failed_attempt',
351
+ 'file_change', 'incident_near_miss'];
352
+
353
+ if (!validTypes.includes(type)) {
354
+ return text(`Error: invalid type "${type}". Valid types: ${validTypes.join(', ')}`);
355
+ }
356
+
357
+ // We need a session_id - get the most recent active session
358
+ const activeSession = db.prepare(
359
+ "SELECT session_id FROM sessions WHERE status = 'active' ORDER BY started_at_epoch DESC LIMIT 1"
360
+ ).get() as { session_id: string } | undefined;
361
+
362
+ if (!activeSession) {
363
+ return text('Error: no active session found. Start a session first.');
364
+ }
365
+
366
+ const importance = (args.importance as number) ?? assignImportance(type);
367
+ const id = addObservation(db, activeSession.session_id, type, title, (args.detail as string) ?? null, {
368
+ importance,
369
+ crRule: args.cr_rule as string | undefined,
370
+ planItem: args.plan_item as string | undefined,
371
+ filesInvolved: args.files as string[] | undefined,
372
+ });
373
+
374
+ return text(`Observation #${id} recorded successfully.\nType: ${type}\nTitle: ${title}\nImportance: ${importance}\nSession: ${activeSession.session_id.slice(0, 8)}...`);
375
+ }
376
+
377
+ // ============================================================
378
+ // Helpers
379
+ // ============================================================
380
+
381
+ function text(content: string): ToolResult {
382
+ return { content: [{ type: 'text', text: content }] };
383
+ }
384
+
385
+ function safeParseJson(json: string, fallback: unknown): unknown {
386
+ try {
387
+ return JSON.parse(json);
388
+ } catch (_e) {
389
+ return fallback;
390
+ }
391
+ }
@@ -0,0 +1,70 @@
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 { getConfig } from './config.ts';
6
+ import { existsSync } from 'fs';
7
+ import { resolve } from 'path';
8
+
9
+ /**
10
+ * Build the middleware import tree by tracing all transitive imports
11
+ * from the middleware entry point. Any file in this tree is subject to
12
+ * Edge Runtime restrictions (no Node.js deps).
13
+ */
14
+ export function buildMiddlewareTree(dataDb: Database.Database): number {
15
+ // Clear existing data
16
+ dataDb.exec('DELETE FROM massu_middleware_tree');
17
+
18
+ const config = getConfig();
19
+ const middlewareFile = config.paths.middleware ?? 'src/middleware.ts';
20
+
21
+ // Only build if the middleware file path is configured and exists conceptually
22
+ if (!middlewareFile) return 0;
23
+
24
+ // BFS through import edges starting from middleware.ts
25
+ const visited = new Set<string>();
26
+ const queue: string[] = [middlewareFile];
27
+ visited.add(middlewareFile);
28
+
29
+ while (queue.length > 0) {
30
+ const current = queue.shift()!;
31
+
32
+ const imports = dataDb.prepare(
33
+ 'SELECT target_file FROM massu_imports WHERE source_file = ?'
34
+ ).all(current) as { target_file: string }[];
35
+
36
+ for (const imp of imports) {
37
+ if (!visited.has(imp.target_file) && imp.target_file.startsWith('src/')) {
38
+ visited.add(imp.target_file);
39
+ queue.push(imp.target_file);
40
+ }
41
+ }
42
+ }
43
+
44
+ // Store the tree
45
+ const insertStmt = dataDb.prepare('INSERT INTO massu_middleware_tree (file) VALUES (?)');
46
+ const insertAll = dataDb.transaction(() => {
47
+ for (const file of visited) {
48
+ insertStmt.run(file);
49
+ }
50
+ });
51
+ insertAll();
52
+
53
+ return visited.size;
54
+ }
55
+
56
+ /**
57
+ * Check if a file is in the middleware import tree.
58
+ */
59
+ export function isInMiddlewareTree(dataDb: Database.Database, file: string): boolean {
60
+ const result = dataDb.prepare('SELECT 1 FROM massu_middleware_tree WHERE file = ?').get(file);
61
+ return result !== undefined;
62
+ }
63
+
64
+ /**
65
+ * Get all files in the middleware import tree.
66
+ */
67
+ export function getMiddlewareTree(dataDb: Database.Database): string[] {
68
+ const rows = dataDb.prepare('SELECT file FROM massu_middleware_tree ORDER BY file').all() as { file: string }[];
69
+ return rows.map(r => r.file);
70
+ }