@gramatr/client 0.5.1

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 (65) hide show
  1. package/AGENTS.md +17 -0
  2. package/CLAUDE.md +18 -0
  3. package/README.md +108 -0
  4. package/bin/add-api-key.ts +264 -0
  5. package/bin/clean-legacy-install.ts +28 -0
  6. package/bin/clear-creds.ts +141 -0
  7. package/bin/get-token.py +3 -0
  8. package/bin/gmtr-login.ts +599 -0
  9. package/bin/gramatr.js +36 -0
  10. package/bin/gramatr.ts +374 -0
  11. package/bin/install.ts +716 -0
  12. package/bin/lib/config.ts +57 -0
  13. package/bin/lib/git.ts +111 -0
  14. package/bin/lib/stdin.ts +53 -0
  15. package/bin/logout.ts +76 -0
  16. package/bin/render-claude-hooks.ts +16 -0
  17. package/bin/statusline.ts +81 -0
  18. package/bin/uninstall.ts +289 -0
  19. package/chatgpt/README.md +95 -0
  20. package/chatgpt/install.ts +140 -0
  21. package/chatgpt/lib/chatgpt-install-utils.ts +89 -0
  22. package/codex/README.md +28 -0
  23. package/codex/hooks/session-start.ts +73 -0
  24. package/codex/hooks/stop.ts +34 -0
  25. package/codex/hooks/user-prompt-submit.ts +79 -0
  26. package/codex/install.ts +116 -0
  27. package/codex/lib/codex-hook-utils.ts +48 -0
  28. package/codex/lib/codex-install-utils.ts +123 -0
  29. package/core/auth.ts +170 -0
  30. package/core/feedback.ts +55 -0
  31. package/core/formatting.ts +179 -0
  32. package/core/install.ts +107 -0
  33. package/core/installer-cli.ts +122 -0
  34. package/core/migration.ts +479 -0
  35. package/core/routing.ts +108 -0
  36. package/core/session.ts +202 -0
  37. package/core/targets.ts +292 -0
  38. package/core/types.ts +179 -0
  39. package/core/version-check.ts +219 -0
  40. package/core/version.ts +47 -0
  41. package/desktop/README.md +72 -0
  42. package/desktop/build-mcpb.ts +166 -0
  43. package/desktop/install.ts +136 -0
  44. package/desktop/lib/desktop-install-utils.ts +70 -0
  45. package/gemini/README.md +95 -0
  46. package/gemini/hooks/session-start.ts +72 -0
  47. package/gemini/hooks/stop.ts +30 -0
  48. package/gemini/hooks/user-prompt-submit.ts +77 -0
  49. package/gemini/install.ts +281 -0
  50. package/gemini/lib/gemini-hook-utils.ts +63 -0
  51. package/gemini/lib/gemini-install-utils.ts +169 -0
  52. package/hooks/GMTRPromptEnricher.hook.ts +651 -0
  53. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  54. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  55. package/hooks/GMTRToolTracker.hook.ts +181 -0
  56. package/hooks/StopOrchestrator.hook.ts +78 -0
  57. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  58. package/hooks/lib/gmtr-hook-utils.ts +770 -0
  59. package/hooks/lib/identity.ts +227 -0
  60. package/hooks/lib/notify.ts +46 -0
  61. package/hooks/lib/paths.ts +104 -0
  62. package/hooks/lib/transcript-parser.ts +452 -0
  63. package/hooks/session-end.hook.ts +168 -0
  64. package/hooks/session-start.hook.ts +501 -0
  65. package/package.json +63 -0
@@ -0,0 +1,452 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * transcript-parser.ts - Claude transcript parsing utilities
4
+ *
5
+ * Shared library for extracting content from Claude Code transcript files.
6
+ * Used by Stop hooks for voice, tab state, and response capture.
7
+ *
8
+ * CLI Usage:
9
+ * bun transcript-parser.ts <transcript_path>
10
+ * bun transcript-parser.ts <transcript_path> --voice
11
+ * bun transcript-parser.ts <transcript_path> --plain
12
+ * bun transcript-parser.ts <transcript_path> --structured
13
+ * bun transcript-parser.ts <transcript_path> --state
14
+ *
15
+ * Module Usage:
16
+ * import { parseTranscript, getLastAssistantMessage } from './transcript-parser'
17
+ */
18
+
19
+ import { readFileSync } from 'fs';
20
+ import { getIdentity } from './identity';
21
+
22
+ const DA_IDENTITY = getIdentity();
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ export interface StructuredResponse {
29
+ date?: string;
30
+ summary?: string;
31
+ analysis?: string;
32
+ actions?: string;
33
+ results?: string;
34
+ status?: string;
35
+ next?: string;
36
+ completed?: string;
37
+ }
38
+
39
+ export type ResponseState = 'awaitingInput' | 'completed' | 'error';
40
+
41
+ export interface ParsedTranscript {
42
+ /** Raw transcript content */
43
+ raw: string;
44
+ /** Last assistant message text */
45
+ lastMessage: string;
46
+ /** Full text from current response turn (all assistant blocks combined) */
47
+ currentResponseText: string;
48
+ /** Voice completion text (for TTS) */
49
+ voiceCompletion: string;
50
+ /** Plain completion text (for tab title) */
51
+ plainCompletion: string;
52
+ /** Structured sections extracted from response */
53
+ structured: StructuredResponse;
54
+ /** Response state for tab coloring */
55
+ responseState: ResponseState;
56
+ /** Last real user prompt text */
57
+ lastUserPrompt: string;
58
+ }
59
+
60
+ // ============================================================================
61
+ // Core Parsing Functions
62
+ // ============================================================================
63
+
64
+ /**
65
+ * Safely convert Claude content (string or array of blocks) to plain text.
66
+ */
67
+ export function contentToText(content: unknown): string {
68
+ if (typeof content === 'string') return content;
69
+ if (Array.isArray(content)) {
70
+ return content
71
+ .map(c => {
72
+ if (typeof c === 'string') return c;
73
+ if (c?.text) return c.text;
74
+ if (c?.content) return contentToText(c.content);
75
+ return '';
76
+ })
77
+ .join(' ')
78
+ .trim();
79
+ }
80
+ return '';
81
+ }
82
+
83
+ /**
84
+ * Parse last assistant message from transcript content.
85
+ * Takes raw content string to avoid re-reading file.
86
+ */
87
+ export function parseLastAssistantMessage(transcriptContent: string): string {
88
+ const lines = transcriptContent.trim().split('\n');
89
+ let lastAssistantMessage = '';
90
+
91
+ for (const line of lines) {
92
+ if (line.trim()) {
93
+ try {
94
+ const entry = JSON.parse(line) as any;
95
+ if (entry.type === 'assistant' && entry.message?.content) {
96
+ const text = contentToText(entry.message.content);
97
+ if (text) {
98
+ lastAssistantMessage = text;
99
+ }
100
+ }
101
+ } catch {
102
+ // Skip invalid JSON lines
103
+ }
104
+ }
105
+ }
106
+
107
+ return lastAssistantMessage;
108
+ }
109
+
110
+ /**
111
+ * Collect assistant text from the CURRENT response turn only.
112
+ * A "turn" is everything after the last human message in the transcript.
113
+ * This prevents voice/completion extraction from picking up stale lines
114
+ * from previous turns when the Stop hook fires.
115
+ *
116
+ * Within a single turn, there may be multiple assistant entries
117
+ * (text → tool_use → tool_result → more text). All are collected.
118
+ */
119
+ export function collectCurrentResponseText(transcriptContent: string): string {
120
+ const lines = transcriptContent.trim().split('\n');
121
+
122
+ // Find the index of the last REAL user prompt.
123
+ // Claude Code transcript uses type='user' for both actual user prompts AND
124
+ // tool_result entries (which are mid-response). Real user prompts have at
125
+ // least one {type:'text'} content block. Tool results only have {type:'tool_result'}.
126
+ let lastHumanIndex = -1;
127
+ for (let i = 0; i < lines.length; i++) {
128
+ if (lines[i].trim()) {
129
+ try {
130
+ const entry = JSON.parse(lines[i]) as any;
131
+ if (entry.type === 'human' || entry.type === 'user') {
132
+ const content = entry.message?.content;
133
+ // String content = real user message
134
+ if (typeof content === 'string') {
135
+ lastHumanIndex = i;
136
+ } else if (Array.isArray(content)) {
137
+ // Check for text blocks — indicates a real user prompt
138
+ const hasText = content.some((b: any) => b?.type === 'text' && b?.text?.trim());
139
+ if (hasText) {
140
+ lastHumanIndex = i;
141
+ }
142
+ }
143
+ }
144
+ } catch {
145
+ // Skip invalid JSON lines
146
+ }
147
+ }
148
+ }
149
+
150
+ // Collect only assistant text AFTER the last human message
151
+ const textParts: string[] = [];
152
+ for (let i = lastHumanIndex + 1; i < lines.length; i++) {
153
+ if (lines[i].trim()) {
154
+ try {
155
+ const entry = JSON.parse(lines[i]) as any;
156
+ if (entry.type === 'assistant' && entry.message?.content) {
157
+ const text = contentToText(entry.message.content);
158
+ if (text) {
159
+ textParts.push(text);
160
+ }
161
+ }
162
+ } catch {
163
+ // Skip invalid JSON lines
164
+ }
165
+ }
166
+ }
167
+
168
+ return textParts.join('\n');
169
+ }
170
+
171
+ export function parseLastUserPrompt(transcriptContent: string): string {
172
+ const lines = transcriptContent.trim().split('\n');
173
+ let lastUserPrompt = '';
174
+
175
+ for (const line of lines) {
176
+ if (!line.trim()) continue;
177
+ try {
178
+ const entry = JSON.parse(line) as any;
179
+ if (entry.type === 'human' || entry.type === 'user') {
180
+ const content = entry.message?.content;
181
+ if (typeof content === 'string' && content.trim()) {
182
+ lastUserPrompt = content.trim();
183
+ } else if (Array.isArray(content)) {
184
+ const text = content
185
+ .filter((block: any) => block?.type === 'text' && typeof block?.text === 'string')
186
+ .map((block: any) => block.text.trim())
187
+ .filter(Boolean)
188
+ .join('\n')
189
+ .trim();
190
+ if (text) lastUserPrompt = text;
191
+ }
192
+ }
193
+ } catch {
194
+ // Ignore malformed rows
195
+ }
196
+ }
197
+
198
+ return lastUserPrompt;
199
+ }
200
+
201
+ /**
202
+ * Get last assistant message from transcript file.
203
+ * Convenience function that reads file and parses.
204
+ */
205
+ export function getLastAssistantMessage(transcriptPath: string): string {
206
+ try {
207
+ const content = readFileSync(transcriptPath, 'utf-8');
208
+ return parseLastAssistantMessage(content);
209
+ } catch (error) {
210
+ console.error('[TranscriptParser] Error reading transcript:', error);
211
+ return '';
212
+ }
213
+ }
214
+
215
+ // ============================================================================
216
+ // Extraction Functions
217
+ // ============================================================================
218
+
219
+ /**
220
+ * Extract voice completion line for TTS.
221
+ * Uses LAST match to avoid capturing mentions in analysis text.
222
+ */
223
+ export function extractVoiceCompletion(text: string): string {
224
+ // Remove system-reminder tags
225
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
226
+
227
+ // Use global flag and find LAST match (voice line is at end of response)
228
+ const completedPatterns = [
229
+ new RegExp(`🗣️\\s*\\*{0,2}${DA_IDENTITY.name}:?\\*{0,2}\\s*(.+?)(?:\\n|$)`, 'gi'),
230
+ /🎯\s*\*{0,2}COMPLETED:?\*{0,2}\s*(.+?)(?:\n|$)/gi,
231
+ ];
232
+
233
+ for (const pattern of completedPatterns) {
234
+ const matches = [...text.matchAll(pattern)];
235
+ if (matches.length > 0) {
236
+ // Use LAST match - the actual voice line at end of response
237
+ const lastMatch = matches[matches.length - 1];
238
+ if (lastMatch && lastMatch[1]) {
239
+ let completed = lastMatch[1].trim();
240
+ // Clean up agent tags
241
+ completed = completed.replace(/^\[AGENT:\w+\]\s*/i, '');
242
+ // Voice server handles sanitization
243
+ return completed.trim();
244
+ }
245
+ }
246
+ }
247
+
248
+ // Don't say anything if no voice line found
249
+ return '';
250
+ }
251
+
252
+ /**
253
+ * Extract plain completion text for display/tab titles.
254
+ * Uses LAST match to avoid capturing mentions in analysis text.
255
+ */
256
+ export function extractCompletionPlain(text: string): string {
257
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
258
+
259
+ // Use global flag and find LAST match (voice line is at end of response)
260
+ const completedPatterns = [
261
+ new RegExp(`🗣️\\s*\\*{0,2}${DA_IDENTITY.name}:?\\*{0,2}\\s*(.+?)(?:\\n|$)`, 'gi'),
262
+ /🎯\s*\*{0,2}COMPLETED:?\*{0,2}\s*(.+?)(?:\n|$)/gi,
263
+ ];
264
+
265
+ for (const pattern of completedPatterns) {
266
+ const matches = [...text.matchAll(pattern)];
267
+ if (matches.length > 0) {
268
+ // Use LAST match - the actual voice line at end of response
269
+ const lastMatch = matches[matches.length - 1];
270
+ if (lastMatch && lastMatch[1]) {
271
+ let completed = lastMatch[1].trim();
272
+ completed = completed.replace(/^\[AGENT:\w+\]\s*/i, '');
273
+ completed = completed.replace(/\[.*?\]/g, '');
274
+ completed = completed.replace(/\*\*/g, '');
275
+ completed = completed.replace(/\*/g, '');
276
+ completed = completed.replace(/[\p{Emoji}\p{Emoji_Component}]/gu, '');
277
+ completed = completed.replace(/\s+/g, ' ').trim();
278
+ return completed;
279
+ }
280
+ }
281
+ }
282
+
283
+ // Fallback: try to extract something meaningful from the response
284
+ const summaryMatch = text.match(/📋\s*\*{0,2}SUMMARY:?\*{0,2}\s*(.+?)(?:\n|$)/i);
285
+ if (summaryMatch && summaryMatch[1]) {
286
+ let summary = summaryMatch[1].trim().slice(0, 30);
287
+ return summary.length > 27 ? summary.slice(0, 27) + '…' : summary;
288
+ }
289
+
290
+ // No voice line found — return empty, let downstream handle fallback
291
+ return '';
292
+ }
293
+
294
+ /**
295
+ * Extract structured sections from response.
296
+ */
297
+ export function extractStructuredSections(text: string): StructuredResponse {
298
+ const result: StructuredResponse = {};
299
+
300
+ text = text.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, '');
301
+
302
+ const patterns: Record<keyof StructuredResponse, RegExp> = {
303
+ date: /📅\s*(.+?)(?:\n|$)/i,
304
+ summary: /📋\s*SUMMARY:\s*(.+?)(?:\n|$)/i,
305
+ analysis: /🔍\s*ANALYSIS:\s*(.+?)(?:\n|$)/i,
306
+ actions: /⚡\s*ACTIONS:\s*(.+?)(?:\n|$)/i,
307
+ results: /✅\s*RESULTS:\s*(.+?)(?:\n|$)/i,
308
+ status: /📊\s*STATUS:\s*(.+?)(?:\n|$)/i,
309
+ next: /➡️\s*NEXT:\s*(.+?)(?:\n|$)/i,
310
+ completed: new RegExp(`(?:🗣️\\s*${DA_IDENTITY.name}:|🎯\\s*COMPLETED:)\\s*(.+?)(?:\\n|$)`, 'i'),
311
+ };
312
+
313
+ for (const [key, pattern] of Object.entries(patterns)) {
314
+ const match = text.match(pattern);
315
+ if (match && match[1]) {
316
+ result[key as keyof StructuredResponse] = match[1].trim();
317
+ }
318
+ }
319
+
320
+ return result;
321
+ }
322
+
323
+ // ============================================================================
324
+ // State Detection
325
+ // ============================================================================
326
+
327
+ /**
328
+ * Detect response state for tab coloring.
329
+ * Takes parsed content to avoid re-reading file.
330
+ */
331
+ export function detectResponseState(lastMessage: string, transcriptContent: string): ResponseState {
332
+ try {
333
+ // Check if the LAST assistant message used AskUserQuestion
334
+ const lines = transcriptContent.trim().split('\n');
335
+ let lastAssistantEntry: any = null;
336
+
337
+ for (const line of lines) {
338
+ try {
339
+ const entry = JSON.parse(line);
340
+ if (entry.type === 'assistant' && entry.message?.content) {
341
+ lastAssistantEntry = entry;
342
+ }
343
+ } catch {}
344
+ }
345
+
346
+ if (lastAssistantEntry?.message?.content) {
347
+ const content = Array.isArray(lastAssistantEntry.message.content)
348
+ ? lastAssistantEntry.message.content
349
+ : [];
350
+ for (const block of content) {
351
+ if (block.type === 'tool_use' && block.name === 'AskUserQuestion') {
352
+ return 'awaitingInput';
353
+ }
354
+ }
355
+ }
356
+ } catch (err) {
357
+ console.error('[TranscriptParser] Error detecting response state:', err);
358
+ }
359
+
360
+ // Check for error indicators
361
+ if (/📊\s*STATUS:.*(?:error|failed|broken|problem|issue)/i.test(lastMessage)) {
362
+ return 'error';
363
+ }
364
+
365
+ const hasErrorKeyword = /\b(?:error|failed|exception|crash|broken)\b/i.test(lastMessage);
366
+ const hasErrorEmoji = /❌|🚨|⚠️/.test(lastMessage);
367
+ if (hasErrorKeyword && hasErrorEmoji) {
368
+ return 'error';
369
+ }
370
+
371
+ return 'completed';
372
+ }
373
+
374
+ // ============================================================================
375
+ // Unified Parser
376
+ // ============================================================================
377
+
378
+ /**
379
+ * Parse transcript and extract all relevant data in one pass.
380
+ * This is the main function for the orchestrator pattern.
381
+ */
382
+ export function parseTranscript(transcriptPath: string): ParsedTranscript {
383
+ try {
384
+ const raw = readFileSync(transcriptPath, 'utf-8');
385
+ const lastMessage = parseLastAssistantMessage(raw);
386
+ // Collect assistant text from CURRENT response turn only.
387
+ // This prevents stale voice lines from previous turns being read
388
+ // when the Stop hook fires. Within the current turn, multiple
389
+ // assistant entries exist (text → tool_use → tool_result → more text).
390
+ const currentResponseText = collectCurrentResponseText(raw);
391
+
392
+ return {
393
+ raw,
394
+ lastMessage,
395
+ currentResponseText,
396
+ voiceCompletion: extractVoiceCompletion(currentResponseText),
397
+ plainCompletion: extractCompletionPlain(currentResponseText),
398
+ structured: extractStructuredSections(currentResponseText),
399
+ responseState: detectResponseState(lastMessage, raw),
400
+ lastUserPrompt: parseLastUserPrompt(raw),
401
+ };
402
+ } catch (error) {
403
+ console.error('[TranscriptParser] Error parsing transcript:', error);
404
+ return {
405
+ raw: '',
406
+ lastMessage: '',
407
+ currentResponseText: '',
408
+ voiceCompletion: '',
409
+ plainCompletion: '',
410
+ structured: {},
411
+ responseState: 'completed',
412
+ lastUserPrompt: '',
413
+ };
414
+ }
415
+ }
416
+
417
+ // ============================================================================
418
+ // CLI
419
+ // ============================================================================
420
+
421
+ if (import.meta.main) {
422
+ const args = process.argv.slice(2);
423
+ const transcriptPath = args.find(a => !a.startsWith('-'));
424
+
425
+ if (!transcriptPath) {
426
+ console.log(`Usage: bun transcript-parser.ts <transcript_path> [options]
427
+
428
+ Options:
429
+ --voice Output voice completion (for TTS)
430
+ --plain Output plain completion (for tab titles)
431
+ --structured Output structured sections as JSON
432
+ --state Output response state
433
+ --all Output full parsed transcript as JSON (default)
434
+ `);
435
+ process.exit(1);
436
+ }
437
+
438
+ const parsed = parseTranscript(transcriptPath);
439
+
440
+ if (args.includes('--voice')) {
441
+ console.log(parsed.voiceCompletion);
442
+ } else if (args.includes('--plain')) {
443
+ console.log(parsed.plainCompletion);
444
+ } else if (args.includes('--structured')) {
445
+ console.log(JSON.stringify(parsed.structured, null, 2));
446
+ } else if (args.includes('--state')) {
447
+ console.log(parsed.responseState);
448
+ } else {
449
+ // Default: output everything
450
+ console.log(JSON.stringify(parsed, null, 2));
451
+ }
452
+ }
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * session-end.hook.ts — gramatr SessionEnd Hook
4
+ *
5
+ * Replaces session-end.sh (176 lines). Tracks session completion,
6
+ * saves commit history, and registers handoff with gramatr server.
7
+ *
8
+ * TRIGGER: SessionEnd
9
+ * OUTPUT: stderr (user display)
10
+ *
11
+ * ZERO external CLI dependencies — no jq, sed, curl, awk.
12
+ */
13
+
14
+ import { writeFileSync } from 'fs';
15
+ import { join } from 'path';
16
+ import {
17
+ readHookInput,
18
+ getGitContext,
19
+ deriveProjectId,
20
+ readGmtrConfig,
21
+ writeGmtrConfig,
22
+ resolveAuthToken,
23
+ callMcpToolRaw,
24
+ getCommitCountSince,
25
+ getCommitLogSince,
26
+ getCommitsSince,
27
+ getFilesChanged,
28
+ appendLine,
29
+ log,
30
+ now,
31
+ } from './lib/gmtr-hook-utils.ts';
32
+
33
+ async function main(): Promise<void> {
34
+ try {
35
+ log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
36
+ log('Claude Code Session Ending...');
37
+ log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
38
+
39
+ const input = await readHookInput();
40
+ const sessionId = input.session_id || 'unknown';
41
+ const reason = input.reason || 'other';
42
+ const timestamp = now();
43
+ const home = process.env.HOME || '';
44
+
45
+ const git = getGitContext();
46
+
47
+ if (git) {
48
+ const projectId = deriveProjectId(git.remote, git.projectName);
49
+
50
+ log(`Project: ${git.projectName} (${projectId})`);
51
+ log(`Session: ${sessionId}`);
52
+ log(`Reason: ${reason}`);
53
+
54
+ // Get commit stats
55
+ const config = readGmtrConfig(git.root);
56
+ const sessionStart =
57
+ config?.current_session?.last_updated || config?.metadata?.updated || '';
58
+
59
+ let commitsCount = 0;
60
+ if (sessionStart) {
61
+ commitsCount = getCommitCountSince(sessionStart);
62
+
63
+ if (commitsCount > 0) {
64
+ log(` Commits made this session: ${commitsCount}`);
65
+
66
+ // Save commit summary for next session
67
+ const commitLog = getCommitLogSince(sessionStart);
68
+ const commitsFile = join(home, '.claude', 'last-session-commits.txt');
69
+ writeFileSync(commitsFile, commitLog.join('\n') + '\n');
70
+ log(' Commit history saved for next session');
71
+ } else {
72
+ log(' No commits this session');
73
+ }
74
+ }
75
+
76
+ // Write session end marker
77
+ const historyFile = join(home, '.claude', 'session-history.log');
78
+ appendLine(historyFile, `Session ended at ${timestamp} (reason: ${reason}, session: ${sessionId})`);
79
+
80
+ // Update settings.json
81
+ if (config) {
82
+ config.metadata = config.metadata || {};
83
+ config.metadata.updated = timestamp;
84
+ config.metadata.last_session_end_reason = reason;
85
+ config.project_id = projectId;
86
+ writeGmtrConfig(git.root, config);
87
+ log(' Updated settings.json');
88
+ }
89
+
90
+ // ── Automatic Handoff to gramatr ──
91
+ const token = resolveAuthToken();
92
+
93
+ if (token) {
94
+ // Build session summary from git data
95
+ let branch = 'unknown';
96
+ try {
97
+ const { execSync } = await import('child_process');
98
+ branch = execSync('git rev-parse --abbrev-ref HEAD', {
99
+ encoding: 'utf8',
100
+ stdio: ['pipe', 'pipe', 'pipe'],
101
+ }).trim();
102
+ } catch {
103
+ // ignore
104
+ }
105
+
106
+ // Build structured handoff summary (5 required sections)
107
+ let commitList: string[] = [];
108
+ let modified: string[] = [];
109
+ if (commitsCount > 0 && sessionStart) {
110
+ commitList = getCommitsSince(sessionStart, 10);
111
+ }
112
+ if (commitsCount > 0) {
113
+ modified = getFilesChanged(`HEAD~${Math.min(commitsCount, 20)}`, 'HEAD', 15);
114
+ }
115
+
116
+ // Section 1: WHERE WE ARE
117
+ let summary = `Session ended (${reason}). Project: ${git.projectName} (${projectId}). Branch: ${branch}.`;
118
+ if (commitsCount > 0) {
119
+ summary += ` Commits: ${commitsCount}. ${commitList.join('; ')}`;
120
+ }
121
+ if (modified.length > 0) {
122
+ summary += ` Files: ${modified.join(', ')}`;
123
+ }
124
+
125
+ // Get session state from config
126
+ const sessionEntityId = config?.current_session?.gmtr_entity_id || '';
127
+ const interactionId = config?.current_session?.interaction_id || '';
128
+
129
+ log('');
130
+ log('Saving session state to gramatr...');
131
+
132
+ // Session lifecycle only — gmtr_session_end records git summary on the session entity.
133
+ // Handoffs are saved by the AGENT in the LEARN phase via gmtr_save_handoff (HARD GATE).
134
+ // The hook does NOT save handoffs — it lacks conversation context.
135
+ try {
136
+ const rawResult = await callMcpToolRaw('gmtr_session_end', {
137
+ entity_id: sessionEntityId || sessionId,
138
+ session_id: sessionId,
139
+ interaction_id: interactionId,
140
+ project_id: projectId,
141
+ summary: summary,
142
+ tool_call_count: 0,
143
+ });
144
+
145
+ if (rawResult && rawResult.includes('"status":"ended"')) {
146
+ log(' Session end recorded');
147
+ } else if (rawResult && rawResult.includes('"isError":true')) {
148
+ log(' Session end had errors (session may not have gmtr entity)');
149
+ } else {
150
+ log(' Session end uncertain (server may be unavailable)');
151
+ }
152
+ } catch {
153
+ log(' Session end uncertain (server may be unavailable)');
154
+ }
155
+ } else {
156
+ log(' No auth token \u2014 skipping gramatr handoff');
157
+ }
158
+ }
159
+
160
+ log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
161
+ log('Session cleanup complete');
162
+ log('\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501');
163
+ } catch (err) {
164
+ log(`[gramatr] session-end error: ${String(err)}`);
165
+ }
166
+ }
167
+
168
+ main();