@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.
- package/LICENSE +71 -0
- package/README.md +2 -2
- package/dist/hooks/cost-tracker.js +149 -11527
- package/dist/hooks/post-edit-context.js +127 -11493
- package/dist/hooks/post-tool-use.js +169 -11550
- package/dist/hooks/pre-compact.js +149 -11530
- package/dist/hooks/pre-delete-check.js +144 -11523
- package/dist/hooks/quality-event.js +149 -11527
- package/dist/hooks/session-end.js +188 -11570
- package/dist/hooks/session-start.js +159 -11534
- package/dist/hooks/user-prompt.js +149 -11530
- package/package.json +14 -19
- package/src/adr-generator.ts +292 -0
- package/src/analytics.ts +373 -0
- package/src/audit-trail.ts +450 -0
- package/src/backfill-sessions.ts +180 -0
- package/src/cli.ts +105 -0
- package/src/cloud-sync.ts +190 -0
- package/src/commands/doctor.ts +300 -0
- package/src/commands/init.ts +395 -0
- package/src/commands/install-hooks.ts +26 -0
- package/src/config.ts +357 -0
- package/src/cost-tracker.ts +355 -0
- package/src/db.ts +233 -0
- package/src/dependency-scorer.ts +337 -0
- package/src/docs-map.json +100 -0
- package/src/docs-tools.ts +517 -0
- package/src/domains.ts +181 -0
- package/src/hooks/cost-tracker.ts +66 -0
- package/src/hooks/intent-suggester.ts +131 -0
- package/src/hooks/post-edit-context.ts +91 -0
- package/src/hooks/post-tool-use.ts +175 -0
- package/src/hooks/pre-compact.ts +146 -0
- package/src/hooks/pre-delete-check.ts +153 -0
- package/src/hooks/quality-event.ts +127 -0
- package/src/hooks/security-gate.ts +121 -0
- package/src/hooks/session-end.ts +467 -0
- package/src/hooks/session-start.ts +210 -0
- package/src/hooks/user-prompt.ts +91 -0
- package/src/import-resolver.ts +224 -0
- package/src/memory-db.ts +1376 -0
- package/src/memory-tools.ts +391 -0
- package/src/middleware-tree.ts +70 -0
- package/src/observability-tools.ts +343 -0
- package/src/observation-extractor.ts +411 -0
- package/src/page-deps.ts +283 -0
- package/src/prompt-analyzer.ts +332 -0
- package/src/regression-detector.ts +319 -0
- package/src/rules.ts +57 -0
- package/src/schema-mapper.ts +232 -0
- package/src/security-scorer.ts +405 -0
- package/src/security-utils.ts +133 -0
- package/src/sentinel-db.ts +578 -0
- package/src/sentinel-scanner.ts +405 -0
- package/src/sentinel-tools.ts +512 -0
- package/src/sentinel-types.ts +140 -0
- package/src/server.ts +189 -0
- package/src/session-archiver.ts +112 -0
- package/src/session-state-generator.ts +174 -0
- package/src/team-knowledge.ts +407 -0
- package/src/tools.ts +847 -0
- package/src/transcript-parser.ts +458 -0
- package/src/trpc-index.ts +214 -0
- package/src/validate-features-runner.ts +106 -0
- package/src/validation-engine.ts +358 -0
- package/dist/cli.js +0 -7890
- package/dist/server.js +0 -7008
|
@@ -0,0 +1,450 @@
|
|
|
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
|
+
// Compliance Audit Trail
|
|
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
|
+
export interface AuditEntry {
|
|
18
|
+
eventType: 'code_change' | 'rule_enforced' | 'approval' | 'review' | 'commit' | 'compaction';
|
|
19
|
+
actor: 'ai' | 'human' | 'hook' | 'agent';
|
|
20
|
+
filePath?: string;
|
|
21
|
+
changeType?: 'create' | 'edit' | 'delete';
|
|
22
|
+
evidence?: string;
|
|
23
|
+
metadata?: Record<string, unknown>;
|
|
24
|
+
sessionId?: string;
|
|
25
|
+
modelId?: string;
|
|
26
|
+
approvalStatus?: 'auto_approved' | 'human_approved' | 'pending' | 'denied';
|
|
27
|
+
rulesInEffect?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Default audit formats */
|
|
31
|
+
const DEFAULT_FORMATS = ['summary', 'detailed', 'soc2'];
|
|
32
|
+
|
|
33
|
+
/** Default retention days */
|
|
34
|
+
const DEFAULT_RETENTION_DAYS = 365;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get configured audit formats.
|
|
38
|
+
*/
|
|
39
|
+
function getAuditFormats(): string[] {
|
|
40
|
+
return getConfig().governance?.audit?.formats ?? DEFAULT_FORMATS;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Log an audit entry.
|
|
45
|
+
*/
|
|
46
|
+
export function logAuditEntry(
|
|
47
|
+
db: Database.Database,
|
|
48
|
+
entry: AuditEntry
|
|
49
|
+
): void {
|
|
50
|
+
db.prepare(`
|
|
51
|
+
INSERT INTO audit_log (session_id, event_type, actor, model_id, file_path, change_type, rules_in_effect, approval_status, evidence, metadata)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
53
|
+
`).run(
|
|
54
|
+
entry.sessionId ?? null,
|
|
55
|
+
entry.eventType,
|
|
56
|
+
entry.actor,
|
|
57
|
+
entry.modelId ?? null,
|
|
58
|
+
entry.filePath ?? null,
|
|
59
|
+
entry.changeType ?? null,
|
|
60
|
+
entry.rulesInEffect ?? null,
|
|
61
|
+
entry.approvalStatus ?? null,
|
|
62
|
+
entry.evidence ?? null,
|
|
63
|
+
entry.metadata ? JSON.stringify(entry.metadata) : null
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Query audit log with filters.
|
|
69
|
+
*/
|
|
70
|
+
export function queryAuditLog(
|
|
71
|
+
db: Database.Database,
|
|
72
|
+
options: {
|
|
73
|
+
eventType?: string;
|
|
74
|
+
actor?: string;
|
|
75
|
+
days?: number;
|
|
76
|
+
limit?: number;
|
|
77
|
+
filePath?: string;
|
|
78
|
+
changeType?: string;
|
|
79
|
+
}
|
|
80
|
+
): Array<Record<string, unknown>> {
|
|
81
|
+
let sql = 'SELECT * FROM audit_log WHERE 1=1';
|
|
82
|
+
const params: (string | number)[] = [];
|
|
83
|
+
|
|
84
|
+
if (options.eventType) {
|
|
85
|
+
sql += ' AND event_type = ?';
|
|
86
|
+
params.push(options.eventType);
|
|
87
|
+
}
|
|
88
|
+
if (options.actor) {
|
|
89
|
+
sql += ' AND actor = ?';
|
|
90
|
+
params.push(options.actor);
|
|
91
|
+
}
|
|
92
|
+
if (options.days) {
|
|
93
|
+
sql += ' AND timestamp >= datetime(\'now\', ?)';
|
|
94
|
+
params.push(`-${options.days} days`);
|
|
95
|
+
}
|
|
96
|
+
if (options.filePath) {
|
|
97
|
+
sql += ' AND file_path = ?';
|
|
98
|
+
params.push(options.filePath);
|
|
99
|
+
}
|
|
100
|
+
if (options.changeType) {
|
|
101
|
+
sql += ' AND change_type = ?';
|
|
102
|
+
params.push(options.changeType);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
sql += ' ORDER BY timestamp DESC';
|
|
106
|
+
|
|
107
|
+
if (options.limit) {
|
|
108
|
+
sql += ' LIMIT ?';
|
|
109
|
+
params.push(options.limit);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return db.prepare(sql).all(...params) as Array<Record<string, unknown>>;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Get audit chain for a file - chronological audit history.
|
|
117
|
+
*/
|
|
118
|
+
export function getFileChain(
|
|
119
|
+
db: Database.Database,
|
|
120
|
+
filePath: string
|
|
121
|
+
): Array<Record<string, unknown>> {
|
|
122
|
+
return db.prepare(
|
|
123
|
+
'SELECT * FROM audit_log WHERE file_path = ? ORDER BY timestamp ASC'
|
|
124
|
+
).all(filePath) as Array<Record<string, unknown>>;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Backfill audit log from session observations.
|
|
129
|
+
*/
|
|
130
|
+
export function backfillAuditLog(db: Database.Database): number {
|
|
131
|
+
const observations = db.prepare(`
|
|
132
|
+
SELECT o.id, o.type, o.detail, o.files_involved, o.session_id, o.created_at
|
|
133
|
+
FROM observations o
|
|
134
|
+
LEFT JOIN audit_log a ON a.evidence = o.detail AND a.session_id = o.session_id
|
|
135
|
+
WHERE a.id IS NULL
|
|
136
|
+
AND o.type IN ('bugfix', 'cr_violation', 'vr_check', 'incident', 'decision')
|
|
137
|
+
ORDER BY o.created_at ASC
|
|
138
|
+
LIMIT 1000
|
|
139
|
+
`).all() as Array<Record<string, unknown>>;
|
|
140
|
+
|
|
141
|
+
let backfilled = 0;
|
|
142
|
+
for (const obs of observations) {
|
|
143
|
+
const files = obs.files_involved ? JSON.parse(obs.files_involved as string) : [];
|
|
144
|
+
const eventType = (obs.type === 'cr_violation') ? 'rule_enforced' as const
|
|
145
|
+
: (obs.type === 'vr_check') ? 'review' as const
|
|
146
|
+
: 'code_change' as const;
|
|
147
|
+
|
|
148
|
+
logAuditEntry(db, {
|
|
149
|
+
eventType,
|
|
150
|
+
actor: 'ai',
|
|
151
|
+
filePath: files[0] ?? undefined,
|
|
152
|
+
changeType: 'edit',
|
|
153
|
+
evidence: obs.detail as string,
|
|
154
|
+
sessionId: obs.session_id as string,
|
|
155
|
+
metadata: { original_type: obs.type, backfilled: true },
|
|
156
|
+
});
|
|
157
|
+
backfilled++;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return backfilled;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================
|
|
164
|
+
// MCP Tool Definitions & Handlers
|
|
165
|
+
// ============================================================
|
|
166
|
+
|
|
167
|
+
export function getAuditToolDefinitions(): ToolDefinition[] {
|
|
168
|
+
return [
|
|
169
|
+
{
|
|
170
|
+
name: p('audit_log'),
|
|
171
|
+
description: 'Query the audit log. Filter by event type, actor, file, or time range.',
|
|
172
|
+
inputSchema: {
|
|
173
|
+
type: 'object',
|
|
174
|
+
properties: {
|
|
175
|
+
event_type: { type: 'string', description: 'Filter by event type: code_change, rule_enforced, approval, review, commit, compaction' },
|
|
176
|
+
actor: { type: 'string', description: 'Filter by actor: ai, human, hook, agent' },
|
|
177
|
+
file_path: { type: 'string', description: 'Filter by file path' },
|
|
178
|
+
change_type: { type: 'string', description: 'Filter by change type: create, edit, delete' },
|
|
179
|
+
days: { type: 'number', description: 'Days to look back (default: 30)' },
|
|
180
|
+
limit: { type: 'number', description: 'Max results (default: 50)' },
|
|
181
|
+
},
|
|
182
|
+
required: [],
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
{
|
|
186
|
+
name: p('audit_report'),
|
|
187
|
+
description: 'Generate an audit report in a specified format (summary, detailed, soc2).',
|
|
188
|
+
inputSchema: {
|
|
189
|
+
type: 'object',
|
|
190
|
+
properties: {
|
|
191
|
+
format: { type: 'string', description: 'Report format: summary, detailed, soc2 (default: summary)' },
|
|
192
|
+
days: { type: 'number', description: 'Days to cover (default: 30)' },
|
|
193
|
+
},
|
|
194
|
+
required: [],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: p('audit_chain'),
|
|
199
|
+
description: 'Get the complete audit trail for a specific file. Shows all changes, reviews, and decisions chronologically.',
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: 'object',
|
|
202
|
+
properties: {
|
|
203
|
+
file: { type: 'string', description: 'File path to trace' },
|
|
204
|
+
},
|
|
205
|
+
required: ['file'],
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const AUDIT_BASE_NAMES = new Set(['audit_log', 'audit_report', 'audit_chain']);
|
|
212
|
+
|
|
213
|
+
export function isAuditTool(name: string): boolean {
|
|
214
|
+
const pfx = getConfig().toolPrefix + '_';
|
|
215
|
+
const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
|
|
216
|
+
return AUDIT_BASE_NAMES.has(baseName);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function handleAuditToolCall(
|
|
220
|
+
name: string,
|
|
221
|
+
args: Record<string, unknown>,
|
|
222
|
+
memoryDb: Database.Database
|
|
223
|
+
): ToolResult {
|
|
224
|
+
try {
|
|
225
|
+
const pfx = getConfig().toolPrefix + '_';
|
|
226
|
+
const baseName = name.startsWith(pfx) ? name.slice(pfx.length) : name;
|
|
227
|
+
|
|
228
|
+
switch (baseName) {
|
|
229
|
+
case 'audit_log':
|
|
230
|
+
return handleAuditLog(args, memoryDb);
|
|
231
|
+
case 'audit_report':
|
|
232
|
+
return handleAuditReport(args, memoryDb);
|
|
233
|
+
case 'audit_chain':
|
|
234
|
+
return handleAuditChain(args, memoryDb);
|
|
235
|
+
default:
|
|
236
|
+
return text(`Unknown audit tool: ${name}`);
|
|
237
|
+
}
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return text(`Error in ${name}: ${error instanceof Error ? error.message : String(error)}\n\nUsage: ${p('audit_log')} { severity: "critical" }, ${p('audit_report')} { format: "soc2" }`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function handleAuditLog(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
244
|
+
const entries = queryAuditLog(db, {
|
|
245
|
+
eventType: args.event_type as string | undefined,
|
|
246
|
+
actor: args.actor as string | undefined,
|
|
247
|
+
filePath: args.file_path as string | undefined,
|
|
248
|
+
changeType: args.change_type as string | undefined,
|
|
249
|
+
days: (args.days as number) ?? 30,
|
|
250
|
+
limit: (args.limit as number) ?? 50,
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (entries.length === 0) {
|
|
254
|
+
return text(`No audit log entries found matching the filters. Audit entries are recorded automatically during code changes, rule checks, and commits. Try broadening your search: ${p('audit_log')} { days: 90 } for a longer time range, or ${p('audit_log')} {} with no filters.`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const lines = [
|
|
258
|
+
`## Audit Log (${entries.length} entries)`,
|
|
259
|
+
'',
|
|
260
|
+
'| Timestamp | Event | Actor | Change | File | Evidence |',
|
|
261
|
+
'|-----------|-------|-------|--------|------|----------|',
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
for (const entry of entries) {
|
|
265
|
+
const file = entry.file_path ? (entry.file_path as string).split('/').pop() : '-';
|
|
266
|
+
const evidence = entry.evidence ? (entry.evidence as string).slice(0, 50) : '-';
|
|
267
|
+
lines.push(
|
|
268
|
+
`| ${(entry.timestamp as string).slice(0, 16)} | ${entry.event_type} | ${entry.actor} | ${entry.change_type ?? '-'} | ${file} | ${evidence} |`
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return text(lines.join('\n'));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function handleAuditReport(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
276
|
+
const format = (args.format as string) ?? 'summary';
|
|
277
|
+
const days = (args.days as number) ?? 30;
|
|
278
|
+
|
|
279
|
+
const availableFormats = getAuditFormats();
|
|
280
|
+
if (!availableFormats.includes(format)) {
|
|
281
|
+
return text(`Unknown report format: "${format}". Available formats: ${availableFormats.join(', ')}. Example: ${p('audit_report')} { format: "soc2", days: 30 }. Configure additional formats via the governance.audit.formats config key.`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const entries = queryAuditLog(db, { days });
|
|
285
|
+
|
|
286
|
+
if (entries.length === 0) {
|
|
287
|
+
return text(`No audit entries found in the last ${days} days. Audit entries are recorded automatically during code changes, reviews, and commits. Try: ${p('audit_log')} { days: 90 } for a longer time range.`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Count by event type and actor
|
|
291
|
+
const byEventType: Record<string, number> = {};
|
|
292
|
+
const byActor: Record<string, number> = {};
|
|
293
|
+
for (const e of entries) {
|
|
294
|
+
const et = e.event_type as string;
|
|
295
|
+
byEventType[et] = (byEventType[et] ?? 0) + 1;
|
|
296
|
+
const actor = e.actor as string;
|
|
297
|
+
byActor[actor] = (byActor[actor] ?? 0) + 1;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (format === 'soc2') {
|
|
301
|
+
return generateSoc2Report(entries, byEventType, byActor, days);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (format === 'detailed') {
|
|
305
|
+
return generateDetailedReport(entries, byEventType, byActor, days);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Summary format
|
|
309
|
+
const lines = [
|
|
310
|
+
`## Audit Summary Report (${days} days)`,
|
|
311
|
+
`Generated: ${new Date().toISOString().slice(0, 16)}`,
|
|
312
|
+
'',
|
|
313
|
+
'### By Event Type',
|
|
314
|
+
`| Event Type | Count |`,
|
|
315
|
+
`|------------|-------|`,
|
|
316
|
+
];
|
|
317
|
+
|
|
318
|
+
for (const [et, count] of Object.entries(byEventType).sort((a, b) => b[1] - a[1])) {
|
|
319
|
+
lines.push(`| ${et} | ${count} |`);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
lines.push(`| **Total** | **${entries.length}** |`);
|
|
323
|
+
lines.push('');
|
|
324
|
+
lines.push('### By Actor');
|
|
325
|
+
lines.push(`| Actor | Count |`);
|
|
326
|
+
lines.push(`|-------|-------|`);
|
|
327
|
+
|
|
328
|
+
for (const [actor, count] of Object.entries(byActor).sort((a, b) => b[1] - a[1])) {
|
|
329
|
+
lines.push(`| ${actor} | ${count} |`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return text(lines.join('\n'));
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function generateSoc2Report(
|
|
336
|
+
entries: Array<Record<string, unknown>>,
|
|
337
|
+
byEventType: Record<string, number>,
|
|
338
|
+
byActor: Record<string, number>,
|
|
339
|
+
days: number
|
|
340
|
+
): ToolResult {
|
|
341
|
+
const lines = [
|
|
342
|
+
`# SOC 2 Compliance Report`,
|
|
343
|
+
`Period: Last ${days} days`,
|
|
344
|
+
`Generated: ${new Date().toISOString()}`,
|
|
345
|
+
'',
|
|
346
|
+
'## 1. Control Environment',
|
|
347
|
+
`Total audit events: ${entries.length}`,
|
|
348
|
+
`Human-initiated: ${byActor['human'] ?? 0}`,
|
|
349
|
+
`AI-initiated: ${byActor['ai'] ?? 0}`,
|
|
350
|
+
`Hook-triggered: ${byActor['hook'] ?? 0}`,
|
|
351
|
+
'',
|
|
352
|
+
'## 2. Change Management',
|
|
353
|
+
`Code changes logged: ${byEventType['code_change'] ?? 0}`,
|
|
354
|
+
`Rule enforcements: ${byEventType['rule_enforced'] ?? 0}`,
|
|
355
|
+
`Approvals recorded: ${byEventType['approval'] ?? 0}`,
|
|
356
|
+
`Reviews: ${byEventType['review'] ?? 0}`,
|
|
357
|
+
`Commits: ${byEventType['commit'] ?? 0}`,
|
|
358
|
+
'',
|
|
359
|
+
'## 3. Approval Status',
|
|
360
|
+
];
|
|
361
|
+
|
|
362
|
+
const pendingApprovals = entries.filter(e => e.approval_status === 'pending');
|
|
363
|
+
const deniedApprovals = entries.filter(e => e.approval_status === 'denied');
|
|
364
|
+
lines.push(`Pending approvals: ${pendingApprovals.length}`);
|
|
365
|
+
lines.push(`Denied approvals: ${deniedApprovals.length}`);
|
|
366
|
+
lines.push('');
|
|
367
|
+
lines.push('## 4. Events Requiring Review');
|
|
368
|
+
|
|
369
|
+
const reviewEntries = entries.filter(e => e.approval_status === 'denied' || e.event_type === 'rule_enforced');
|
|
370
|
+
if (reviewEntries.length > 0) {
|
|
371
|
+
for (const e of reviewEntries.slice(0, 20)) {
|
|
372
|
+
lines.push(`- [${(e.timestamp as string).slice(0, 16)}] ${e.event_type}: ${((e.evidence as string) ?? '').slice(0, 100)}`);
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
lines.push('No events requiring review in this period.');
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
return text(lines.join('\n'));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function generateDetailedReport(
|
|
382
|
+
entries: Array<Record<string, unknown>>,
|
|
383
|
+
byEventType: Record<string, number>,
|
|
384
|
+
byActor: Record<string, number>,
|
|
385
|
+
days: number
|
|
386
|
+
): ToolResult {
|
|
387
|
+
const lines = [
|
|
388
|
+
`## Detailed Audit Report (${days} days)`,
|
|
389
|
+
`Total events: ${entries.length}`,
|
|
390
|
+
'',
|
|
391
|
+
'### Event Type Distribution',
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
for (const [et, count] of Object.entries(byEventType).sort((a, b) => b[1] - a[1])) {
|
|
395
|
+
lines.push(`- ${et}: ${count}`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
lines.push('');
|
|
399
|
+
lines.push('### Actor Distribution');
|
|
400
|
+
for (const [actor, count] of Object.entries(byActor).sort((a, b) => b[1] - a[1])) {
|
|
401
|
+
lines.push(`- ${actor}: ${count}`);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
lines.push('');
|
|
405
|
+
lines.push('### All Events');
|
|
406
|
+
|
|
407
|
+
for (const e of entries.slice(0, 100)) {
|
|
408
|
+
lines.push(`#### ${(e.timestamp as string).slice(0, 16)} [${e.event_type}]`);
|
|
409
|
+
lines.push(`Actor: ${e.actor} | Change: ${e.change_type ?? '-'} | Approval: ${e.approval_status ?? '-'}`);
|
|
410
|
+
if (e.file_path) lines.push(`File: ${e.file_path}`);
|
|
411
|
+
if (e.evidence) lines.push(`Evidence: ${e.evidence}`);
|
|
412
|
+
lines.push('');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (entries.length > 100) {
|
|
416
|
+
lines.push(`... and ${entries.length - 100} more entries`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
return text(lines.join('\n'));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function handleAuditChain(args: Record<string, unknown>, db: Database.Database): ToolResult {
|
|
423
|
+
const filePath = args.file as string;
|
|
424
|
+
if (!filePath) return text(`Usage: ${p('audit_chain')} { file: "src/path/to/file.ts" } - Get complete audit history for a file.`);
|
|
425
|
+
|
|
426
|
+
const chain = getFileChain(db, filePath);
|
|
427
|
+
|
|
428
|
+
if (chain.length === 0) {
|
|
429
|
+
return text(`No audit entries found for "${filePath}". Audit entries are recorded automatically when files are modified, reviewed, or validated. Try: ${p('audit_log')} {} to see all recent audit entries across all files.`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const lines = [
|
|
433
|
+
`## Audit Chain: ${filePath}`,
|
|
434
|
+
`Total events: ${chain.length}`,
|
|
435
|
+
'',
|
|
436
|
+
];
|
|
437
|
+
|
|
438
|
+
for (const entry of chain) {
|
|
439
|
+
lines.push(`### ${(entry.timestamp as string).slice(0, 16)} [${entry.event_type}]`);
|
|
440
|
+
lines.push(`**${entry.actor}** (${entry.change_type ?? 'unknown'})`);
|
|
441
|
+
if (entry.evidence) lines.push(entry.evidence as string);
|
|
442
|
+
lines.push('');
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
return text(lines.join('\n'));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function text(content: string): ToolResult {
|
|
449
|
+
return { content: [{ type: 'text', text: content }] };
|
|
450
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
3
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
// ============================================================
|
|
6
|
+
// P6-001: Backfill Tool
|
|
7
|
+
// One-time script to parse existing transcript JSONL files and
|
|
8
|
+
// populate the memory DB with historical session data.
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
import { readdirSync, statSync, existsSync } from 'fs';
|
|
12
|
+
import { resolve, basename } from 'path';
|
|
13
|
+
import { getMemoryDb, createSession, addObservation, addSummary, addUserPrompt, deduplicateFailedAttempt } from './memory-db.ts';
|
|
14
|
+
import { parseTranscript, extractUserMessages, getLastAssistantMessage } from './transcript-parser.ts';
|
|
15
|
+
import { extractObservationsFromEntries } from './observation-extractor.ts';
|
|
16
|
+
import { getProjectRoot } from './config.ts';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Auto-detect the Claude Code project transcript directory.
|
|
20
|
+
* Claude Code stores transcripts at ~/.claude/projects/-<escaped-path>/
|
|
21
|
+
*/
|
|
22
|
+
function findTranscriptDir(): string {
|
|
23
|
+
const home = process.env.HOME ?? '~';
|
|
24
|
+
const projectRoot = getProjectRoot();
|
|
25
|
+
// Claude Code escapes the path by replacing / with -
|
|
26
|
+
const escapedPath = projectRoot.replace(/\//g, '-');
|
|
27
|
+
const candidate = resolve(home, '.claude/projects', escapedPath);
|
|
28
|
+
if (existsSync(candidate)) return candidate;
|
|
29
|
+
// Fallback: scan .claude/projects/ for directories matching the project name
|
|
30
|
+
const projectsDir = resolve(home, '.claude/projects');
|
|
31
|
+
if (existsSync(projectsDir)) {
|
|
32
|
+
try {
|
|
33
|
+
const entries = readdirSync(projectsDir);
|
|
34
|
+
const projectName = basename(projectRoot);
|
|
35
|
+
const match = entries.find(e => e.includes(projectName));
|
|
36
|
+
if (match) return resolve(projectsDir, match);
|
|
37
|
+
} catch {
|
|
38
|
+
// Ignore
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return candidate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_SESSIONS = 20;
|
|
45
|
+
|
|
46
|
+
async function main(): Promise<void> {
|
|
47
|
+
console.log('=== Massu Memory Backfill ===');
|
|
48
|
+
const TRANSCRIPT_DIR = findTranscriptDir();
|
|
49
|
+
console.log(`Transcript directory: ${TRANSCRIPT_DIR}`);
|
|
50
|
+
|
|
51
|
+
// 1. List JSONL files
|
|
52
|
+
let files: string[];
|
|
53
|
+
try {
|
|
54
|
+
files = readdirSync(TRANSCRIPT_DIR)
|
|
55
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
56
|
+
.map(f => ({
|
|
57
|
+
name: f,
|
|
58
|
+
path: resolve(TRANSCRIPT_DIR, f),
|
|
59
|
+
mtime: statSync(resolve(TRANSCRIPT_DIR, f)).mtimeMs,
|
|
60
|
+
}))
|
|
61
|
+
.sort((a, b) => b.mtime - a.mtime) // Most recent first
|
|
62
|
+
.slice(0, MAX_SESSIONS)
|
|
63
|
+
.map(f => f.path);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
console.error(`Failed to read transcript directory: ${error instanceof Error ? error.message : String(error)}`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`Found ${files.length} transcript files (processing most recent ${MAX_SESSIONS})`);
|
|
71
|
+
|
|
72
|
+
const db = getMemoryDb();
|
|
73
|
+
let totalObservations = 0;
|
|
74
|
+
let totalSessions = 0;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
for (const filePath of files) {
|
|
78
|
+
const sessionId = filePath.split('/').pop()?.replace('.jsonl', '') ?? 'unknown';
|
|
79
|
+
console.log(`\nProcessing: ${sessionId.slice(0, 8)}...`);
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Parse transcript
|
|
83
|
+
const entries = await parseTranscript(filePath);
|
|
84
|
+
if (entries.length === 0) {
|
|
85
|
+
console.log(' Skipped: empty transcript');
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Extract session metadata
|
|
90
|
+
const firstEntry = entries.find(e => e.sessionId);
|
|
91
|
+
const gitBranch = firstEntry?.gitBranch;
|
|
92
|
+
const startTimestamp = firstEntry?.timestamp;
|
|
93
|
+
|
|
94
|
+
// Create session (INSERT OR IGNORE for idempotency)
|
|
95
|
+
createSession(db, sessionId, { branch: gitBranch });
|
|
96
|
+
|
|
97
|
+
// Update started_at if we have a timestamp
|
|
98
|
+
if (startTimestamp) {
|
|
99
|
+
db.prepare('UPDATE sessions SET started_at = ?, started_at_epoch = ? WHERE session_id = ? AND started_at_epoch = 0')
|
|
100
|
+
.run(startTimestamp, Math.floor(new Date(startTimestamp).getTime() / 1000), sessionId);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Extract user prompts
|
|
104
|
+
const userMessages = extractUserMessages(entries);
|
|
105
|
+
for (let i = 0; i < Math.min(userMessages.length, 50); i++) {
|
|
106
|
+
try {
|
|
107
|
+
addUserPrompt(db, sessionId, userMessages[i].text.slice(0, 5000), i + 1);
|
|
108
|
+
} catch (_e) {
|
|
109
|
+
// Skip duplicate prompts
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Extract observations (with noise filtering applied)
|
|
114
|
+
const observations = extractObservationsFromEntries(entries);
|
|
115
|
+
let sessionObsCount = 0;
|
|
116
|
+
|
|
117
|
+
for (const obs of observations) {
|
|
118
|
+
try {
|
|
119
|
+
if (obs.type === 'failed_attempt') {
|
|
120
|
+
deduplicateFailedAttempt(db, sessionId, obs.title, obs.detail, obs.opts);
|
|
121
|
+
} else {
|
|
122
|
+
addObservation(db, sessionId, obs.type, obs.title, obs.detail, obs.opts);
|
|
123
|
+
}
|
|
124
|
+
sessionObsCount++;
|
|
125
|
+
} catch (_e) {
|
|
126
|
+
// Skip on error
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
totalObservations += sessionObsCount;
|
|
131
|
+
totalSessions++;
|
|
132
|
+
console.log(` Extracted: ${sessionObsCount} observations, ${Math.min(userMessages.length, 50)} prompts`);
|
|
133
|
+
|
|
134
|
+
// Generate summary from observations
|
|
135
|
+
const completed = observations
|
|
136
|
+
.filter(o => ['feature', 'bugfix', 'refactor'].includes(o.type))
|
|
137
|
+
.map(o => `- ${o.title}`)
|
|
138
|
+
.join('\n');
|
|
139
|
+
|
|
140
|
+
const decisions = observations
|
|
141
|
+
.filter(o => o.type === 'decision')
|
|
142
|
+
.map(o => `- ${o.title}`)
|
|
143
|
+
.join('\n');
|
|
144
|
+
|
|
145
|
+
const failedAttempts = observations
|
|
146
|
+
.filter(o => o.type === 'failed_attempt')
|
|
147
|
+
.map(o => `- ${o.title}`)
|
|
148
|
+
.join('\n');
|
|
149
|
+
|
|
150
|
+
if (observations.length > 0) {
|
|
151
|
+
try {
|
|
152
|
+
addSummary(db, sessionId, {
|
|
153
|
+
request: userMessages[0]?.text?.slice(0, 500),
|
|
154
|
+
completed: completed || undefined,
|
|
155
|
+
decisions: decisions || undefined,
|
|
156
|
+
failedAttempts: failedAttempts || undefined,
|
|
157
|
+
});
|
|
158
|
+
} catch (_e) {
|
|
159
|
+
// Skip summary errors
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Mark as completed
|
|
164
|
+
db.prepare("UPDATE sessions SET status = 'completed' WHERE session_id = ? AND status = 'active'")
|
|
165
|
+
.run(sessionId);
|
|
166
|
+
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.log(` Error: ${error instanceof Error ? error.message : String(error)}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} finally {
|
|
172
|
+
db.close();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
console.log('\n=== Backfill Complete ===');
|
|
176
|
+
console.log(`Sessions processed: ${totalSessions}`);
|
|
177
|
+
console.log(`Total observations: ${totalObservations}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
main().catch(console.error);
|