@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,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();