@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,467 @@
|
|
|
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
|
+
// P3-003: Stop (Session End) Hook
|
|
7
|
+
// Generates session summary and archives CURRENT.md.
|
|
8
|
+
// Dependencies: P1-002, P5-001, P5-002
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
import { getMemoryDb, endSession, addSummary, createSession, addConversationTurn, addToolCallDetail, getLastProcessedLine, setLastProcessedLine } from '../memory-db.ts';
|
|
12
|
+
import { generateCurrentMd } from '../session-state-generator.ts';
|
|
13
|
+
import { archiveAndRegenerate } from '../session-archiver.ts';
|
|
14
|
+
import { parseTranscriptFrom, estimateTokens } from '../transcript-parser.ts';
|
|
15
|
+
import { syncToCloud, drainSyncQueue } from '../cloud-sync.ts';
|
|
16
|
+
import { calculateQualityScore, storeQualityScore, backfillQualityScores } from '../analytics.ts';
|
|
17
|
+
import { extractTokenUsage, calculateCost, storeSessionCost } from '../cost-tracker.ts';
|
|
18
|
+
import { analyzeSessionPrompts } from '../prompt-analyzer.ts';
|
|
19
|
+
import type { SyncPayload } from '../cloud-sync.ts';
|
|
20
|
+
import type { SessionSummary } from '../memory-db.ts';
|
|
21
|
+
import type { TranscriptEntry, TranscriptContentBlock } from '../transcript-parser.ts';
|
|
22
|
+
|
|
23
|
+
interface HookInput {
|
|
24
|
+
session_id: string;
|
|
25
|
+
transcript_path: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
hook_event_name: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function main(): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
const input = await readStdin();
|
|
33
|
+
const hookInput = JSON.parse(input) as HookInput;
|
|
34
|
+
const { session_id } = hookInput;
|
|
35
|
+
|
|
36
|
+
const db = getMemoryDb();
|
|
37
|
+
try {
|
|
38
|
+
// Ensure session exists
|
|
39
|
+
createSession(db, session_id);
|
|
40
|
+
|
|
41
|
+
// 1. Get all observations for this session
|
|
42
|
+
const observations = db.prepare(
|
|
43
|
+
'SELECT * FROM observations WHERE session_id = ? ORDER BY created_at_epoch ASC'
|
|
44
|
+
).all(session_id) as Array<Record<string, unknown>>;
|
|
45
|
+
|
|
46
|
+
// 2. Get user prompts
|
|
47
|
+
const prompts = db.prepare(
|
|
48
|
+
'SELECT prompt_text FROM user_prompts WHERE session_id = ? ORDER BY prompt_number ASC'
|
|
49
|
+
).all(session_id) as Array<{ prompt_text: string }>;
|
|
50
|
+
|
|
51
|
+
// 3. Generate structured summary from observations
|
|
52
|
+
const summary = buildSummaryFromObservations(observations, prompts);
|
|
53
|
+
|
|
54
|
+
// 4. Insert summary
|
|
55
|
+
addSummary(db, session_id, summary);
|
|
56
|
+
|
|
57
|
+
// 4.5. Capture conversation turns and tool call details from transcript (P2-002)
|
|
58
|
+
try {
|
|
59
|
+
await captureConversationData(db, session_id, hookInput.transcript_path);
|
|
60
|
+
} catch (_captureErr) {
|
|
61
|
+
// Best-effort: never block session end
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4.6. Calculate and store quality score
|
|
65
|
+
try {
|
|
66
|
+
const { score, breakdown } = calculateQualityScore(db, session_id);
|
|
67
|
+
if (score !== 50) {
|
|
68
|
+
storeQualityScore(db, session_id, score, breakdown);
|
|
69
|
+
}
|
|
70
|
+
backfillQualityScores(db);
|
|
71
|
+
} catch (_qualityErr) {
|
|
72
|
+
// Best-effort: never block session end
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 4.7. Calculate and store session cost
|
|
76
|
+
try {
|
|
77
|
+
const { entries } = await parseTranscriptFrom(hookInput.transcript_path, 0);
|
|
78
|
+
const tokenUsage = extractTokenUsage(entries);
|
|
79
|
+
const cost = calculateCost(tokenUsage);
|
|
80
|
+
|
|
81
|
+
storeSessionCost(db, session_id, tokenUsage, cost);
|
|
82
|
+
} catch (_costErr) {
|
|
83
|
+
// Best-effort: never block session end
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 4.8. Analyze prompt effectiveness
|
|
87
|
+
try {
|
|
88
|
+
analyzeSessionPrompts(db, session_id);
|
|
89
|
+
} catch (_promptErr) {
|
|
90
|
+
// Best-effort: never block session end
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 5. Mark session as completed
|
|
94
|
+
endSession(db, session_id, 'completed');
|
|
95
|
+
|
|
96
|
+
// 6. Auto-generate CURRENT.md and archive old one
|
|
97
|
+
archiveAndRegenerate(db, session_id);
|
|
98
|
+
|
|
99
|
+
// 7. Cloud sync (if enabled)
|
|
100
|
+
// Order: drain pending queue first, then sync current session
|
|
101
|
+
try {
|
|
102
|
+
// 7a. Drain pending sync queue
|
|
103
|
+
await drainSyncQueue(db);
|
|
104
|
+
|
|
105
|
+
// 7b. Sync current session data
|
|
106
|
+
const syncPayload = buildSyncPayload(session_id, observations, summary);
|
|
107
|
+
const result = await syncToCloud(db, syncPayload);
|
|
108
|
+
if (!result.success && result.error) {
|
|
109
|
+
// Payload already enqueued by syncToCloud on failure
|
|
110
|
+
}
|
|
111
|
+
} catch (_syncErr) {
|
|
112
|
+
// Non-blocking: sync failure never blocks session end
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
db.close();
|
|
116
|
+
}
|
|
117
|
+
} catch (_e) {
|
|
118
|
+
// Best-effort: never block Claude Code
|
|
119
|
+
}
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build a sync payload from the current session data.
|
|
125
|
+
*/
|
|
126
|
+
function buildSyncPayload(
|
|
127
|
+
sessionId: string,
|
|
128
|
+
observations: Array<Record<string, unknown>>,
|
|
129
|
+
summary: SessionSummary
|
|
130
|
+
): SyncPayload {
|
|
131
|
+
return {
|
|
132
|
+
sessions: [{
|
|
133
|
+
local_session_id: sessionId,
|
|
134
|
+
summary: summary.request ?? undefined,
|
|
135
|
+
started_at: undefined, // Will be filled from session data if available
|
|
136
|
+
ended_at: new Date().toISOString(),
|
|
137
|
+
turns: 0,
|
|
138
|
+
tokens_used: 0,
|
|
139
|
+
estimated_cost: 0,
|
|
140
|
+
tools_used: [],
|
|
141
|
+
}],
|
|
142
|
+
observations: observations.map((o, idx) => ({
|
|
143
|
+
local_observation_id: `${sessionId}_obs_${idx}`,
|
|
144
|
+
session_id: sessionId,
|
|
145
|
+
type: o.type as string,
|
|
146
|
+
content: (o.title as string) + (o.detail ? `: ${o.detail}` : ''),
|
|
147
|
+
importance: (o.importance as number) ?? 3,
|
|
148
|
+
file_path: undefined,
|
|
149
|
+
})),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildSummaryFromObservations(
|
|
154
|
+
observations: Array<Record<string, unknown>>,
|
|
155
|
+
prompts: Array<{ prompt_text: string }>
|
|
156
|
+
): SessionSummary {
|
|
157
|
+
// request = first user prompt
|
|
158
|
+
const request = prompts[0]?.prompt_text?.slice(0, 500) ?? undefined;
|
|
159
|
+
|
|
160
|
+
// investigated = discovery observations
|
|
161
|
+
const discoveries = observations
|
|
162
|
+
.filter(o => o.type === 'discovery')
|
|
163
|
+
.map(o => (o.title as string))
|
|
164
|
+
.join('; ');
|
|
165
|
+
|
|
166
|
+
// decisions = decision observations
|
|
167
|
+
const decisions = observations
|
|
168
|
+
.filter(o => o.type === 'decision')
|
|
169
|
+
.map(o => `- ${o.title}`)
|
|
170
|
+
.join('\n');
|
|
171
|
+
|
|
172
|
+
// completed = feature/bugfix/refactor observations
|
|
173
|
+
const completed = observations
|
|
174
|
+
.filter(o => ['feature', 'bugfix', 'refactor'].includes(o.type as string))
|
|
175
|
+
.map(o => `- ${o.title}`)
|
|
176
|
+
.join('\n');
|
|
177
|
+
|
|
178
|
+
// failed_attempts = failed_attempt observations
|
|
179
|
+
const failedAttempts = observations
|
|
180
|
+
.filter(o => o.type === 'failed_attempt')
|
|
181
|
+
.map(o => `- ${o.title}`)
|
|
182
|
+
.join('\n');
|
|
183
|
+
|
|
184
|
+
// next_steps = observations from last 10% if no completion markers
|
|
185
|
+
const lastTenPercent = observations.slice(Math.floor(observations.length * 0.9));
|
|
186
|
+
const hasCompletion = completed.length > 0;
|
|
187
|
+
const nextSteps = hasCompletion ? undefined : lastTenPercent
|
|
188
|
+
.map(o => `- [${o.type}] ${o.title}`)
|
|
189
|
+
.join('\n');
|
|
190
|
+
|
|
191
|
+
// files created/modified
|
|
192
|
+
const filesCreated: string[] = [];
|
|
193
|
+
const filesModified: string[] = [];
|
|
194
|
+
for (const o of observations) {
|
|
195
|
+
if (o.type !== 'file_change') continue;
|
|
196
|
+
const files = safeParseJson(o.files_involved as string, []) as string[];
|
|
197
|
+
const title = o.title as string;
|
|
198
|
+
if (title.startsWith('Created') || title.startsWith('Created/wrote')) {
|
|
199
|
+
filesCreated.push(...files);
|
|
200
|
+
} else if (title.startsWith('Edited')) {
|
|
201
|
+
filesModified.push(...files);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// verification results
|
|
206
|
+
const verificationResults: Record<string, string> = {};
|
|
207
|
+
for (const o of observations) {
|
|
208
|
+
if (o.type !== 'vr_check') continue;
|
|
209
|
+
const vrType = o.vr_type as string;
|
|
210
|
+
const passed = (o.title as string).includes('PASS');
|
|
211
|
+
if (vrType) verificationResults[vrType] = passed ? 'PASS' : 'FAIL';
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// plan progress
|
|
215
|
+
const planProgress: Record<string, string> = {};
|
|
216
|
+
for (const o of observations) {
|
|
217
|
+
if (!o.plan_item) continue;
|
|
218
|
+
planProgress[o.plan_item as string] = 'in_progress';
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
request,
|
|
223
|
+
investigated: discoveries || undefined,
|
|
224
|
+
decisions: decisions || undefined,
|
|
225
|
+
completed: completed || undefined,
|
|
226
|
+
failedAttempts: failedAttempts || undefined,
|
|
227
|
+
nextSteps,
|
|
228
|
+
filesCreated: [...new Set(filesCreated)],
|
|
229
|
+
filesModified: [...new Set(filesModified)],
|
|
230
|
+
verificationResults,
|
|
231
|
+
planProgress,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function safeParseJson(json: string, fallback: unknown): unknown {
|
|
236
|
+
try {
|
|
237
|
+
return JSON.parse(json);
|
|
238
|
+
} catch (_e) {
|
|
239
|
+
return fallback;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Capture conversation turns and tool call details from the JSONL transcript.
|
|
245
|
+
* Uses incremental parsing to only process new lines since last invocation.
|
|
246
|
+
* P2-002 + P2-003: Stop hook conversation capture with state tracking.
|
|
247
|
+
*/
|
|
248
|
+
async function captureConversationData(
|
|
249
|
+
db: import('better-sqlite3').Database,
|
|
250
|
+
sessionId: string,
|
|
251
|
+
transcriptPath: string
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
if (!transcriptPath) return;
|
|
254
|
+
|
|
255
|
+
// P2-003: Incremental parsing - only process new lines
|
|
256
|
+
const lastLine = getLastProcessedLine(db, sessionId);
|
|
257
|
+
const { entries, totalLines } = await parseTranscriptFrom(transcriptPath, lastLine);
|
|
258
|
+
|
|
259
|
+
if (entries.length === 0) {
|
|
260
|
+
setLastProcessedLine(db, sessionId, totalLines);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Group entries into turns (user prompt -> assistant response(s) with tool calls)
|
|
265
|
+
const turns = groupEntriesIntoTurns(entries);
|
|
266
|
+
|
|
267
|
+
// Use a transaction for batch insert (P4-002: performance safeguard)
|
|
268
|
+
const insertTurns = db.transaction(() => {
|
|
269
|
+
// Determine starting turn number (continue from existing turns)
|
|
270
|
+
const existingMax = db.prepare(
|
|
271
|
+
'SELECT MAX(turn_number) as max_turn FROM conversation_turns WHERE session_id = ?'
|
|
272
|
+
).get(sessionId) as { max_turn: number | null };
|
|
273
|
+
let turnNumber = (existingMax.max_turn ?? 0) + 1;
|
|
274
|
+
|
|
275
|
+
for (const turn of turns) {
|
|
276
|
+
const toolCallSummaries = turn.toolCalls.map(tc => ({
|
|
277
|
+
name: tc.toolName,
|
|
278
|
+
input_summary: summarizeToolInput(tc.toolName, tc.input).slice(0, 200),
|
|
279
|
+
is_error: tc.isError ?? false,
|
|
280
|
+
}));
|
|
281
|
+
|
|
282
|
+
// P4-001: assistant_response capped at 10000 chars
|
|
283
|
+
const assistantText = turn.assistantText?.slice(0, 10000) ?? null;
|
|
284
|
+
|
|
285
|
+
addConversationTurn(
|
|
286
|
+
db, sessionId, turnNumber,
|
|
287
|
+
turn.userPrompt,
|
|
288
|
+
assistantText,
|
|
289
|
+
toolCallSummaries.length > 0 ? JSON.stringify(toolCallSummaries) : null,
|
|
290
|
+
turn.toolCalls.length,
|
|
291
|
+
estimateTokens(turn.userPrompt),
|
|
292
|
+
assistantText ? estimateTokens(assistantText) : 0
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
// Insert tool call details for this turn (all tools, no filtering)
|
|
296
|
+
for (const tc of turn.toolCalls) {
|
|
297
|
+
const inputStr = JSON.stringify(tc.input);
|
|
298
|
+
const outputStr = tc.result ?? '';
|
|
299
|
+
const files = extractFilesFromToolCall(tc.toolName, tc.input);
|
|
300
|
+
|
|
301
|
+
addToolCallDetail(
|
|
302
|
+
db, sessionId, turnNumber,
|
|
303
|
+
tc.toolName,
|
|
304
|
+
summarizeToolInput(tc.toolName, tc.input),
|
|
305
|
+
inputStr.length,
|
|
306
|
+
outputStr.length,
|
|
307
|
+
!(tc.isError ?? false),
|
|
308
|
+
files.length > 0 ? files : undefined
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
turnNumber++;
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
insertTurns();
|
|
317
|
+
|
|
318
|
+
// Update last processed line
|
|
319
|
+
setLastProcessedLine(db, sessionId, totalLines);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
interface ConversationTurn {
|
|
323
|
+
userPrompt: string;
|
|
324
|
+
assistantText: string | null;
|
|
325
|
+
toolCalls: Array<{
|
|
326
|
+
toolName: string;
|
|
327
|
+
toolUseId: string;
|
|
328
|
+
input: Record<string, unknown>;
|
|
329
|
+
result?: string;
|
|
330
|
+
isError?: boolean;
|
|
331
|
+
}>;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Group transcript entries into conversation turns.
|
|
336
|
+
* A turn starts with a user message and includes all subsequent assistant messages
|
|
337
|
+
* and tool calls until the next user message.
|
|
338
|
+
*/
|
|
339
|
+
function groupEntriesIntoTurns(entries: TranscriptEntry[]): ConversationTurn[] {
|
|
340
|
+
const turns: ConversationTurn[] = [];
|
|
341
|
+
let currentTurn: ConversationTurn | null = null;
|
|
342
|
+
const toolUseMap = new Map<string, { toolName: string; toolUseId: string; input: Record<string, unknown>; result?: string; isError?: boolean }>();
|
|
343
|
+
|
|
344
|
+
for (const entry of entries) {
|
|
345
|
+
if (entry.type === 'user' && entry.message && !entry.isMeta) {
|
|
346
|
+
// Start a new turn
|
|
347
|
+
if (currentTurn) {
|
|
348
|
+
turns.push(currentTurn);
|
|
349
|
+
}
|
|
350
|
+
const text = getTextFromBlocks(entry.message.content);
|
|
351
|
+
if (text.trim()) {
|
|
352
|
+
currentTurn = {
|
|
353
|
+
userPrompt: text.trim(),
|
|
354
|
+
assistantText: null,
|
|
355
|
+
toolCalls: [],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
} else if (entry.type === 'assistant' && entry.message && currentTurn) {
|
|
359
|
+
// Add assistant text
|
|
360
|
+
const text = getTextFromBlocks(entry.message.content);
|
|
361
|
+
if (text.trim()) {
|
|
362
|
+
currentTurn.assistantText = currentTurn.assistantText
|
|
363
|
+
? currentTurn.assistantText + '\n' + text.trim()
|
|
364
|
+
: text.trim();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Extract tool calls from this assistant message
|
|
368
|
+
for (const block of entry.message.content) {
|
|
369
|
+
if (block.type === 'tool_use') {
|
|
370
|
+
const tc = {
|
|
371
|
+
toolName: (block as { name: string }).name,
|
|
372
|
+
toolUseId: (block as { id: string }).id,
|
|
373
|
+
input: (block as { input: Record<string, unknown> }).input ?? {},
|
|
374
|
+
};
|
|
375
|
+
currentTurn.toolCalls.push(tc);
|
|
376
|
+
toolUseMap.set(tc.toolUseId, tc);
|
|
377
|
+
} else if (block.type === 'tool_result') {
|
|
378
|
+
const toolUseId = (block as { tool_use_id: string }).tool_use_id;
|
|
379
|
+
const existing = toolUseMap.get(toolUseId);
|
|
380
|
+
if (existing) {
|
|
381
|
+
existing.result = getToolResultFromBlock(block);
|
|
382
|
+
existing.isError = (block as { is_error?: boolean }).is_error ?? false;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Push the last turn
|
|
390
|
+
if (currentTurn) {
|
|
391
|
+
turns.push(currentTurn);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return turns;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getTextFromBlocks(content: TranscriptContentBlock[]): string {
|
|
398
|
+
return content
|
|
399
|
+
.filter((block): block is { type: 'text'; text: string } => block.type === 'text')
|
|
400
|
+
.map(block => block.text)
|
|
401
|
+
.join('\n');
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function getToolResultFromBlock(block: TranscriptContentBlock): string {
|
|
405
|
+
const content = (block as { content: string | TranscriptContentBlock[] }).content;
|
|
406
|
+
if (typeof content === 'string') return content;
|
|
407
|
+
if (Array.isArray(content)) {
|
|
408
|
+
return content
|
|
409
|
+
.filter((b): b is { type: 'text'; text: string } => typeof b === 'object' && b !== null && b.type === 'text')
|
|
410
|
+
.map(b => b.text)
|
|
411
|
+
.join('\n');
|
|
412
|
+
}
|
|
413
|
+
return '';
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Create a concise summary of tool input for the tool_input_summary column.
|
|
418
|
+
*/
|
|
419
|
+
function summarizeToolInput(toolName: string, input: Record<string, unknown>): string {
|
|
420
|
+
switch (toolName) {
|
|
421
|
+
case 'Read':
|
|
422
|
+
return `Read ${input.file_path ?? ''}`;
|
|
423
|
+
case 'Write':
|
|
424
|
+
return `Write ${input.file_path ?? ''}`;
|
|
425
|
+
case 'Edit':
|
|
426
|
+
return `Edit ${input.file_path ?? ''}`;
|
|
427
|
+
case 'Bash':
|
|
428
|
+
return `$ ${(input.command as string ?? '').slice(0, 200)}`;
|
|
429
|
+
case 'Grep':
|
|
430
|
+
return `Grep "${input.pattern ?? ''}" in ${input.path ?? '.'}`;
|
|
431
|
+
case 'Glob':
|
|
432
|
+
return `Glob "${input.pattern ?? ''}" in ${input.path ?? '.'}`;
|
|
433
|
+
case 'Task':
|
|
434
|
+
return `Task: ${(input.description as string ?? '').slice(0, 100)}`;
|
|
435
|
+
case 'WebFetch':
|
|
436
|
+
return `Fetch ${input.url ?? ''}`;
|
|
437
|
+
case 'WebSearch':
|
|
438
|
+
return `Search "${input.query ?? ''}"`;
|
|
439
|
+
default:
|
|
440
|
+
return `${toolName}: ${JSON.stringify(input).slice(0, 200)}`;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Extract file paths from a tool call input.
|
|
446
|
+
*/
|
|
447
|
+
function extractFilesFromToolCall(toolName: string, input: Record<string, unknown>): string[] {
|
|
448
|
+
const filePath = input.file_path as string | undefined;
|
|
449
|
+
if (filePath) return [filePath];
|
|
450
|
+
|
|
451
|
+
const path = input.path as string | undefined;
|
|
452
|
+
if (path && !path.startsWith('.') && toolName !== 'Grep') return [path];
|
|
453
|
+
|
|
454
|
+
return [];
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function readStdin(): Promise<string> {
|
|
458
|
+
return new Promise((resolve) => {
|
|
459
|
+
let data = '';
|
|
460
|
+
process.stdin.setEncoding('utf-8');
|
|
461
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
462
|
+
process.stdin.on('end', () => resolve(data));
|
|
463
|
+
setTimeout(() => resolve(data), 5000);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
main();
|
|
@@ -0,0 +1,210 @@
|
|
|
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
|
+
// P3-001: Enhanced SessionStart Hook
|
|
7
|
+
// Injects context from previous sessions into new sessions.
|
|
8
|
+
// Output: plain text to stdout (auto-injected by Claude Code)
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
import { getMemoryDb, getSessionSummaries, getRecentObservations, getFailedAttempts, getCrossTaskProgress, autoDetectTaskId, linkSessionToTask, createSession } from '../memory-db.ts';
|
|
12
|
+
import { getConfig } from '../config.ts';
|
|
13
|
+
import type Database from 'better-sqlite3';
|
|
14
|
+
|
|
15
|
+
interface HookInput {
|
|
16
|
+
session_id: string;
|
|
17
|
+
transcript_path: string;
|
|
18
|
+
cwd: string;
|
|
19
|
+
hook_event_name: string;
|
|
20
|
+
source?: 'startup' | 'resume' | 'clear' | 'compact';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main(): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
// Read stdin
|
|
26
|
+
const input = await readStdin();
|
|
27
|
+
const hookInput = JSON.parse(input) as HookInput;
|
|
28
|
+
const { session_id, source } = hookInput;
|
|
29
|
+
|
|
30
|
+
const db = getMemoryDb();
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
// Create session if not exists
|
|
34
|
+
const gitBranch = await getGitBranch();
|
|
35
|
+
createSession(db, session_id, { branch: gitBranch });
|
|
36
|
+
|
|
37
|
+
// Check if session has a plan_file and link task
|
|
38
|
+
const session = db.prepare('SELECT plan_file, task_id FROM sessions WHERE session_id = ?').get(session_id) as { plan_file: string | null; task_id: string | null } | undefined;
|
|
39
|
+
if (session?.plan_file && !session.task_id) {
|
|
40
|
+
const taskId = autoDetectTaskId(session.plan_file);
|
|
41
|
+
if (taskId) linkSessionToTask(db, session_id, taskId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Token budget based on source
|
|
45
|
+
const tokenBudget = getTokenBudget(source ?? 'startup');
|
|
46
|
+
|
|
47
|
+
// Check if this is the very first session (no prior sessions)
|
|
48
|
+
const sessionCount = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number };
|
|
49
|
+
if (sessionCount.count <= 1 && (source === 'startup' || !source)) {
|
|
50
|
+
process.stdout.write(
|
|
51
|
+
'=== MASSU AI: Active ===\n' +
|
|
52
|
+
'Session memory, code intelligence, and governance are now active.\n' +
|
|
53
|
+
`11 hooks monitoring this session. Type "${getConfig().toolPrefix ?? 'massu'}_sync" to index your codebase.\n` +
|
|
54
|
+
'=== END MASSU ===\n\n'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Build context
|
|
59
|
+
const context = buildContext(db, session_id, source ?? 'startup', tokenBudget, session?.task_id ?? null);
|
|
60
|
+
|
|
61
|
+
if (context.trim()) {
|
|
62
|
+
process.stdout.write(context);
|
|
63
|
+
}
|
|
64
|
+
} finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
} catch (_e) {
|
|
68
|
+
// Best-effort: never block Claude Code
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getTokenBudget(source: string): number {
|
|
74
|
+
switch (source) {
|
|
75
|
+
case 'compact': return 4000;
|
|
76
|
+
case 'startup': return 2000;
|
|
77
|
+
case 'resume': return 1000;
|
|
78
|
+
case 'clear': return 2000;
|
|
79
|
+
default: return 2000;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function buildContext(db: Database.Database, sessionId: string, source: string, tokenBudget: number, taskId: string | null): string {
|
|
84
|
+
const sections: Array<{ text: string; importance: number }> = [];
|
|
85
|
+
|
|
86
|
+
// 1. Failed attempts (highest priority - DON'T RETRY warnings)
|
|
87
|
+
const failures = getFailedAttempts(db, undefined, 10);
|
|
88
|
+
if (failures.length > 0) {
|
|
89
|
+
let failText = '### Failed Attempts (DO NOT RETRY)\n';
|
|
90
|
+
for (const f of failures) {
|
|
91
|
+
const recurrence = f.recurrence_count > 1 ? ` (${f.recurrence_count}x)` : '';
|
|
92
|
+
failText += `- ${f.title}${recurrence}\n`;
|
|
93
|
+
}
|
|
94
|
+
sections.push({ text: failText, importance: 10 });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. For compact: include current session's own observations
|
|
98
|
+
if (source === 'compact') {
|
|
99
|
+
const currentObs = getRecentObservations(db, 30, sessionId);
|
|
100
|
+
if (currentObs.length > 0) {
|
|
101
|
+
let currentText = '### Current Session Observations (restored after compaction)\n';
|
|
102
|
+
for (const obs of currentObs) {
|
|
103
|
+
currentText += `- [${obs.type}] ${obs.title}\n`;
|
|
104
|
+
}
|
|
105
|
+
sections.push({ text: currentText, importance: 9 });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 3. Recent session summaries
|
|
110
|
+
const summaryCount = source === 'compact' ? 5 : 3;
|
|
111
|
+
const summaries = getSessionSummaries(db, summaryCount);
|
|
112
|
+
if (summaries.length > 0) {
|
|
113
|
+
for (const s of summaries) {
|
|
114
|
+
let sumText = `### Session (${s.created_at.split('T')[0]})\n`;
|
|
115
|
+
if (s.request) sumText += `**Task**: ${s.request.slice(0, 200)}\n`;
|
|
116
|
+
if (s.completed) sumText += `**Completed**: ${s.completed.slice(0, 300)}\n`;
|
|
117
|
+
if (s.failed_attempts) sumText += `**Failed**: ${s.failed_attempts.slice(0, 200)}\n`;
|
|
118
|
+
|
|
119
|
+
const progress = safeParseJson(s.plan_progress);
|
|
120
|
+
if (progress && Object.keys(progress).length > 0) {
|
|
121
|
+
const total = Object.keys(progress).length;
|
|
122
|
+
const complete = Object.values(progress).filter(v => v === 'complete').length;
|
|
123
|
+
sumText += `**Plan**: ${complete}/${total} complete\n`;
|
|
124
|
+
}
|
|
125
|
+
sections.push({ text: sumText, importance: 7 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 4. Cross-task progress if task_id exists
|
|
130
|
+
if (taskId) {
|
|
131
|
+
const progress = getCrossTaskProgress(db, taskId);
|
|
132
|
+
if (Object.keys(progress).length > 0) {
|
|
133
|
+
const total = Object.keys(progress).length;
|
|
134
|
+
const complete = Object.values(progress).filter(v => v === 'complete').length;
|
|
135
|
+
let progressText = `### Cross-Session Task Progress (${taskId})\n`;
|
|
136
|
+
progressText += `${complete}/${total} items complete\n`;
|
|
137
|
+
sections.push({ text: progressText, importance: 8 });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// 5. Recent observations sorted by importance
|
|
142
|
+
const recentObs = getRecentObservations(db, 20);
|
|
143
|
+
if (recentObs.length > 0) {
|
|
144
|
+
let obsText = '### Recent Observations\n';
|
|
145
|
+
const sorted = [...recentObs].sort((a, b) => b.importance - a.importance);
|
|
146
|
+
for (const obs of sorted) {
|
|
147
|
+
obsText += `- [${obs.type}|imp:${obs.importance}] ${obs.title} (${obs.created_at.split('T')[0]})\n`;
|
|
148
|
+
}
|
|
149
|
+
sections.push({ text: obsText, importance: 5 });
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Fill token budget from high-importance to low-importance
|
|
153
|
+
sections.sort((a, b) => b.importance - a.importance);
|
|
154
|
+
|
|
155
|
+
let usedTokens = 0;
|
|
156
|
+
const headerTokens = estimateTokens('=== Massu Memory: Previous Session Context ===\n\n=== END Massu Memory ===\n');
|
|
157
|
+
usedTokens += headerTokens;
|
|
158
|
+
|
|
159
|
+
const includedSections: string[] = [];
|
|
160
|
+
for (const section of sections) {
|
|
161
|
+
const sectionTokens = estimateTokens(section.text);
|
|
162
|
+
if (usedTokens + sectionTokens <= tokenBudget) {
|
|
163
|
+
includedSections.push(section.text);
|
|
164
|
+
usedTokens += sectionTokens;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (includedSections.length === 0) return '';
|
|
169
|
+
|
|
170
|
+
return `=== Massu Memory: Previous Session Context ===\n\n${includedSections.join('\n')}\n=== END Massu Memory ===\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function estimateTokens(text: string): number {
|
|
174
|
+
return Math.ceil(text.length / 4);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function getGitBranch(): Promise<string | undefined> {
|
|
178
|
+
try {
|
|
179
|
+
const { spawnSync } = await import('child_process');
|
|
180
|
+
const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
181
|
+
encoding: 'utf-8',
|
|
182
|
+
timeout: 5000,
|
|
183
|
+
});
|
|
184
|
+
if (result.status !== 0 || result.error) return undefined;
|
|
185
|
+
return result.stdout.trim();
|
|
186
|
+
} catch (_e) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function readStdin(): Promise<string> {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
let data = '';
|
|
194
|
+
process.stdin.setEncoding('utf-8');
|
|
195
|
+
process.stdin.on('data', (chunk: string) => { data += chunk; });
|
|
196
|
+
process.stdin.on('end', () => resolve(data));
|
|
197
|
+
// Timeout after 3s
|
|
198
|
+
setTimeout(() => resolve(data), 3000);
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function safeParseJson(json: string): Record<string, string> | null {
|
|
203
|
+
try {
|
|
204
|
+
return JSON.parse(json);
|
|
205
|
+
} catch (_e) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
main();
|