gramatr 0.3.0

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 (50) hide show
  1. package/CLAUDE.md +18 -0
  2. package/README.md +78 -0
  3. package/bin/clean-legacy-install.ts +28 -0
  4. package/bin/get-token.py +3 -0
  5. package/bin/gmtr-login.ts +547 -0
  6. package/bin/gramatr.js +33 -0
  7. package/bin/gramatr.ts +248 -0
  8. package/bin/install.ts +756 -0
  9. package/bin/render-claude-hooks.ts +16 -0
  10. package/bin/statusline.ts +437 -0
  11. package/bin/uninstall.ts +289 -0
  12. package/bin/version-sync.ts +46 -0
  13. package/codex/README.md +28 -0
  14. package/codex/hooks/session-start.ts +73 -0
  15. package/codex/hooks/stop.ts +34 -0
  16. package/codex/hooks/user-prompt-submit.ts +76 -0
  17. package/codex/install.ts +99 -0
  18. package/codex/lib/codex-hook-utils.ts +48 -0
  19. package/codex/lib/codex-install-utils.ts +123 -0
  20. package/core/feedback.ts +55 -0
  21. package/core/formatting.ts +167 -0
  22. package/core/install.ts +114 -0
  23. package/core/installer-cli.ts +122 -0
  24. package/core/migration.ts +244 -0
  25. package/core/routing.ts +98 -0
  26. package/core/session.ts +202 -0
  27. package/core/targets.ts +292 -0
  28. package/core/types.ts +178 -0
  29. package/core/version.ts +2 -0
  30. package/gemini/README.md +95 -0
  31. package/gemini/hooks/session-start.ts +72 -0
  32. package/gemini/hooks/stop.ts +30 -0
  33. package/gemini/hooks/user-prompt-submit.ts +74 -0
  34. package/gemini/install.ts +272 -0
  35. package/gemini/lib/gemini-hook-utils.ts +63 -0
  36. package/gemini/lib/gemini-install-utils.ts +169 -0
  37. package/hooks/GMTRPromptEnricher.hook.ts +650 -0
  38. package/hooks/GMTRRatingCapture.hook.ts +198 -0
  39. package/hooks/GMTRSecurityValidator.hook.ts +399 -0
  40. package/hooks/GMTRToolTracker.hook.ts +181 -0
  41. package/hooks/StopOrchestrator.hook.ts +78 -0
  42. package/hooks/gmtr-tool-tracker-utils.ts +105 -0
  43. package/hooks/lib/gmtr-hook-utils.ts +771 -0
  44. package/hooks/lib/identity.ts +227 -0
  45. package/hooks/lib/notify.ts +46 -0
  46. package/hooks/lib/paths.ts +104 -0
  47. package/hooks/lib/transcript-parser.ts +452 -0
  48. package/hooks/session-end.hook.ts +168 -0
  49. package/hooks/session-start.hook.ts +490 -0
  50. package/package.json +54 -0
@@ -0,0 +1,771 @@
1
+ /**
2
+ * gmtr-hook-utils.ts — Shared utilities for all gramatr hooks
3
+ *
4
+ * Provides common functions: stdin reading, git context, config management,
5
+ * MCP transport, auth token resolution, logging.
6
+ *
7
+ * ZERO external CLI dependencies — uses native TypeScript/Bun APIs only.
8
+ */
9
+
10
+ import { execSync } from 'child_process';
11
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync } from 'fs';
12
+ import { join, basename } from 'path';
13
+
14
+ // ── Types ──
15
+
16
+ export interface HookInput {
17
+ session_id: string;
18
+ transcript_path?: string;
19
+ cwd?: string;
20
+ permission_mode?: string;
21
+ hook_event_name?: string;
22
+ message?: string;
23
+ prompt?: string;
24
+ reason?: string;
25
+ }
26
+
27
+ export interface GitContext {
28
+ root: string;
29
+ remote: string;
30
+ branch: string;
31
+ commit: string;
32
+ projectName: string;
33
+ }
34
+
35
+ export interface GmtrConfig {
36
+ version?: string;
37
+ config_version?: string;
38
+ project_entity_id: string | null;
39
+ project_id?: string;
40
+ project_name?: string;
41
+ previously_known_as?: string[];
42
+ git_remote?: string;
43
+ current_session?: {
44
+ session_id?: string;
45
+ transcript_path?: string;
46
+ last_updated?: string;
47
+ token_limit?: number;
48
+ interaction_id?: string;
49
+ gmtr_entity_id?: string;
50
+ helper_pid?: number | null;
51
+ last_classification?: {
52
+ timestamp?: string;
53
+ original_prompt?: string;
54
+ effort_level?: string | null;
55
+ intent_type?: string | null;
56
+ confidence?: number | null;
57
+ classifier_model?: string | null;
58
+ downstream_model?: string | null;
59
+ client_type?: string | null;
60
+ agent_name?: string | null;
61
+ pending_feedback?: boolean;
62
+ feedback_submitted_at?: string | null;
63
+ };
64
+ };
65
+ last_compact?: {
66
+ timestamp?: string;
67
+ summary?: string;
68
+ turns?: unknown[];
69
+ metadata?: {
70
+ files_changed?: string[];
71
+ commits?: string[];
72
+ };
73
+ } | null;
74
+ continuity_stats?: {
75
+ successful_restores?: number;
76
+ failed_restores?: number;
77
+ total_sessions?: number;
78
+ total_compacts?: number;
79
+ total_clears?: number;
80
+ last_restore_quality?: string;
81
+ total_compacts_prevented?: number;
82
+ };
83
+ related_entities?: {
84
+ databases?: unknown[];
85
+ people?: unknown[];
86
+ services?: unknown[];
87
+ concepts?: unknown[];
88
+ projects?: unknown[];
89
+ };
90
+ last_session_id?: string;
91
+ metadata?: {
92
+ created?: string;
93
+ updated?: string;
94
+ last_session_end_reason?: string;
95
+ };
96
+ migrated_at?: string;
97
+ }
98
+
99
+ export interface MctToolCallError {
100
+ reason:
101
+ | 'auth'
102
+ | 'http_error'
103
+ | 'mcp_error'
104
+ | 'parse_error'
105
+ | 'timeout'
106
+ | 'network_error'
107
+ | 'unknown';
108
+ detail: string;
109
+ status?: number;
110
+ }
111
+
112
+ export interface MctToolCallResult<T = unknown> {
113
+ data: T | null;
114
+ error: MctToolCallError | null;
115
+ rawText: string | null;
116
+ }
117
+
118
+ // ── Constants ──
119
+
120
+ const HOME = process.env.HOME || process.env.USERPROFILE || '';
121
+ const DEFAULT_MCP_URL = 'https://api.gramatr.com/mcp';
122
+
123
+ // ── Logging ──
124
+
125
+ /** Write message to stderr (visible to user in terminal) */
126
+ export function log(msg: string): void {
127
+ process.stderr.write(msg + '\n');
128
+ }
129
+
130
+ // ── Timestamps ──
131
+
132
+ /** ISO timestamp in UTC */
133
+ export function now(): string {
134
+ return new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
135
+ }
136
+
137
+ // ── Stdin Reading ──
138
+
139
+ /** Read and parse JSON from stdin (hook input) */
140
+ export async function readHookInput(): Promise<HookInput> {
141
+ const chunks: Buffer[] = [];
142
+ await new Promise<void>((resolve) => {
143
+ const timeout = setTimeout(() => { process.stdin.destroy(); resolve(); }, 500);
144
+ process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
145
+ process.stdin.on('end', () => { clearTimeout(timeout); resolve(); });
146
+ process.stdin.on('error', () => { clearTimeout(timeout); resolve(); });
147
+ process.stdin.resume();
148
+ });
149
+ const raw = Buffer.concat(chunks).toString('utf8');
150
+ if (!raw.trim()) return { session_id: 'unknown' };
151
+ return JSON.parse(raw) as HookInput;
152
+ }
153
+
154
+ // ── Git Context ──
155
+
156
+ /** Get git context for current directory. Returns null if not in a git repo. */
157
+ export function getGitContext(): GitContext | null {
158
+ try {
159
+ const isGit = execSync('git rev-parse --is-inside-work-tree', {
160
+ encoding: 'utf8',
161
+ stdio: ['pipe', 'pipe', 'pipe'],
162
+ }).trim();
163
+ if (isGit !== 'true') return null;
164
+
165
+ const root = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
166
+ let remote = 'no-remote';
167
+ try {
168
+ remote = execSync('git config --get remote.origin.url', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
169
+ } catch {
170
+ // no remote configured
171
+ }
172
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
173
+ const commit = execSync('git rev-parse --short HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
174
+ const projectName = basename(root);
175
+
176
+ return { root, remote, branch, commit, projectName };
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+
182
+ // ── Project ID ──
183
+
184
+ /**
185
+ * Derive normalized project_id from git remote URL.
186
+ * Handles: https://github.com/org/repo.git, git@github.com:org/repo.git, etc.
187
+ * Returns org/repo format, or fallback project name.
188
+ */
189
+ export function deriveProjectId(gitRemote: string, fallbackName?: string): string {
190
+ if (!gitRemote || gitRemote === 'no-remote') {
191
+ return fallbackName || 'unknown';
192
+ }
193
+ const match = gitRemote.match(/[/:]([\w.-]+)\/([\w.-]+?)(?:\.git)?$/);
194
+ if (match) return `${match[1]}/${match[2]}`;
195
+ return fallbackName || 'unknown';
196
+ }
197
+
198
+ // ── Config Management ──
199
+
200
+ /** Get the path to .gmtr/settings.json for a given root directory */
201
+ export function getConfigPath(rootDir: string): string {
202
+ return join(rootDir, '.gmtr', 'settings.json');
203
+ }
204
+
205
+ /** Read .gmtr/settings.json. Returns null if not found or invalid. */
206
+ export function readGmtrConfig(rootDir: string): GmtrConfig | null {
207
+ try {
208
+ const configPath = getConfigPath(rootDir);
209
+ if (!existsSync(configPath)) return null;
210
+ const raw = readFileSync(configPath, 'utf8');
211
+ return JSON.parse(raw) as GmtrConfig;
212
+ } catch {
213
+ return null;
214
+ }
215
+ }
216
+
217
+ /** Write .gmtr/settings.json atomically (write .tmp, then rename) */
218
+ export function writeGmtrConfig(rootDir: string, config: GmtrConfig): void {
219
+ const configPath = getConfigPath(rootDir);
220
+ const tmpPath = configPath + '.tmp';
221
+ const dir = join(rootDir, '.gmtr');
222
+ if (!existsSync(dir)) {
223
+ mkdirSync(dir, { recursive: true });
224
+ }
225
+ writeFileSync(tmpPath, JSON.stringify(config, null, 2) + '\n', 'utf8');
226
+ renameSync(tmpPath, configPath);
227
+ }
228
+
229
+ export function saveLastClassification(
230
+ rootDir: string,
231
+ classification: NonNullable<GmtrConfig['current_session']>['last_classification'],
232
+ ): void {
233
+ const config = readGmtrConfig(rootDir);
234
+ if (!config) return;
235
+ config.current_session = config.current_session || {};
236
+ config.current_session.last_classification = classification;
237
+ if (config.current_session.session_id && !config.last_session_id) {
238
+ config.last_session_id = config.current_session.session_id;
239
+ }
240
+ config.metadata = config.metadata || {};
241
+ config.metadata.updated = now();
242
+ writeGmtrConfig(rootDir, config);
243
+ }
244
+
245
+ export function markClassificationFeedbackSubmitted(rootDir: string, submittedAt: string = now()): void {
246
+ const config = readGmtrConfig(rootDir);
247
+ if (!config?.current_session?.last_classification) return;
248
+ config.current_session.last_classification.pending_feedback = false;
249
+ config.current_session.last_classification.feedback_submitted_at = submittedAt;
250
+ config.metadata = config.metadata || {};
251
+ config.metadata.updated = submittedAt;
252
+ writeGmtrConfig(rootDir, config);
253
+ }
254
+
255
+ /** Migrate config from v2.0 to v2.1 if needed */
256
+ export function migrateConfig(config: GmtrConfig, projectId: string): GmtrConfig {
257
+ const version = config.config_version || '0';
258
+ if (version !== '0' && version !== 'null') {
259
+ // Already migrated — just ensure project_id is current
260
+ config.project_id = projectId;
261
+ return config;
262
+ }
263
+
264
+ log(' Migrating settings.json to v2.1...');
265
+
266
+ // Collect old identifiers
267
+ const oldProjectId = config.project_id || config.project_name || '';
268
+ const oldProjectName = config.project_name || '';
269
+
270
+ // Build previously_known_as list
271
+ const knownAs = new Set<string>(config.previously_known_as || []);
272
+ if (oldProjectId && oldProjectId !== projectId) {
273
+ knownAs.add(oldProjectId);
274
+ }
275
+ if (oldProjectName && oldProjectName !== projectId && oldProjectName !== oldProjectId) {
276
+ knownAs.add(oldProjectName);
277
+ }
278
+
279
+ config.config_version = '2.1';
280
+ config.project_id = projectId;
281
+ config.previously_known_as = Array.from(knownAs);
282
+ config.migrated_at = now();
283
+
284
+ log(` Done migrating to v2.1 (project_id: ${projectId}, aliases: [${Array.from(knownAs).join(', ')}])`);
285
+
286
+ return config;
287
+ }
288
+
289
+ /** Create a fresh v2.1 config */
290
+ export function createDefaultConfig(options: {
291
+ projectId: string;
292
+ projectName: string;
293
+ gitRemote: string;
294
+ sessionId: string;
295
+ transcriptPath: string;
296
+ }): GmtrConfig {
297
+ const ts = now();
298
+ return {
299
+ version: '2.0',
300
+ config_version: '2.1',
301
+ project_entity_id: null,
302
+ project_id: options.projectId,
303
+ project_name: options.projectName,
304
+ previously_known_as: [],
305
+ git_remote: options.gitRemote,
306
+ current_session: {
307
+ session_id: options.sessionId,
308
+ transcript_path: options.transcriptPath,
309
+ last_updated: ts,
310
+ token_limit: 200000,
311
+ },
312
+ last_compact: null,
313
+ continuity_stats: {
314
+ successful_restores: 0,
315
+ failed_restores: 0,
316
+ total_sessions: 1,
317
+ total_compacts: 0,
318
+ total_clears: 0,
319
+ last_restore_quality: 'none',
320
+ total_compacts_prevented: 0,
321
+ },
322
+ related_entities: {
323
+ databases: [],
324
+ people: [],
325
+ services: [],
326
+ concepts: [],
327
+ projects: [],
328
+ },
329
+ last_session_id: options.sessionId,
330
+ metadata: {
331
+ created: ts,
332
+ updated: ts,
333
+ last_session_end_reason: 'new',
334
+ },
335
+ };
336
+ }
337
+
338
+ // ── Auth Token Resolution ──
339
+
340
+ /**
341
+ * Resolve auth token. gramatr credentials NEVER live in CLI-specific config files.
342
+ *
343
+ * Priority:
344
+ * 1. ~/.gmtr.json (canonical, gramatr-owned, vendor-agnostic)
345
+ * 2. GMTR_TOKEN env var (CI, headless override)
346
+ * 3. ~/gmtr-client/settings.json auth.api_key (legacy, will be migrated)
347
+ *
348
+ * Token is NEVER stored in ~/.claude.json, ~/.codex/, or ~/.gemini/.
349
+ */
350
+ export function resolveAuthToken(): string | null {
351
+ // 1. ~/.gmtr.json (canonical source — written by installer, read by all platforms)
352
+ try {
353
+ const configPath = join(HOME, '.gmtr.json');
354
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
355
+ if (config.token) return config.token;
356
+ } catch {
357
+ // No config file or parse error
358
+ }
359
+
360
+ // 2. GMTR_TOKEN env var (CI, headless, or shell profile override)
361
+ if (process.env.GMTR_TOKEN) return process.env.GMTR_TOKEN;
362
+
363
+ // 3. Legacy: AIOS_MCP_TOKEN env var (deprecated)
364
+ if (process.env.AIOS_MCP_TOKEN) return process.env.AIOS_MCP_TOKEN;
365
+
366
+ // 4. Legacy: ~/gmtr-client/settings.json (will be migrated to ~/.gmtr.json)
367
+ try {
368
+ const gmtrDir = process.env.GMTR_DIR || join(HOME, 'gmtr-client');
369
+ const settingsPath = join(gmtrDir, 'settings.json');
370
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
371
+ if (settings.auth?.api_key && settings.auth.api_key !== 'REPLACE_WITH_YOUR_API_KEY') {
372
+ return settings.auth.api_key;
373
+ }
374
+ } catch {
375
+ // Settings file not found or parse error
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ // ── MCP URL Resolution ──
382
+
383
+ /**
384
+ * Resolve MCP server URL from config files.
385
+ * Priority: ~/gmtr-client/settings.json > GMTR_URL env > ~/.claude.json > default
386
+ */
387
+ export function resolveMcpUrl(): string {
388
+ // 1. ~/gmtr-client/settings.json (canonical, vendor-agnostic)
389
+ try {
390
+ const gmtrDir = process.env.GMTR_DIR || join(HOME, 'gmtr-client');
391
+ const settingsPath = join(gmtrDir, 'settings.json');
392
+ const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
393
+ if (settings.auth?.server_url) return settings.auth.server_url;
394
+ } catch {
395
+ // Settings file not found or parse error
396
+ }
397
+
398
+ // 2. Environment variable
399
+ if (process.env.GMTR_URL) return process.env.GMTR_URL;
400
+
401
+ // 3. ~/.claude.json (Claude-specific MCP config)
402
+ try {
403
+ const claudeJson = join(HOME, '.claude.json');
404
+ if (existsSync(claudeJson)) {
405
+ const config = JSON.parse(readFileSync(claudeJson, 'utf8'));
406
+ const url = config?.mcpServers?.gramatr?.url;
407
+ if (url) return url;
408
+ }
409
+ } catch {
410
+ // Not found or parse error
411
+ }
412
+
413
+ // 3. ~/.claude/settings.json
414
+ try {
415
+ const settingsJson = join(HOME, '.claude', 'settings.json');
416
+ if (existsSync(settingsJson)) {
417
+ const config = JSON.parse(readFileSync(settingsJson, 'utf8'));
418
+ const url = config?.mcpServers?.gramatr?.url;
419
+ if (url) return url;
420
+ }
421
+ } catch {
422
+ // Not found or parse error
423
+ }
424
+
425
+ // 4. Default
426
+ return DEFAULT_MCP_URL;
427
+ }
428
+
429
+ // ── MCP Transport ──
430
+
431
+ /**
432
+ * Call an MCP tool via the gramatr server.
433
+ * Handles SSE response parsing, auth, and timeouts.
434
+ */
435
+ export async function callMcpTool(
436
+ toolName: string,
437
+ args: Record<string, unknown>,
438
+ timeoutMs: number = 10000,
439
+ ): Promise<unknown | null> {
440
+ const result = await callMcpToolDetailed(toolName, args, timeoutMs);
441
+ return result.data;
442
+ }
443
+
444
+ export async function callMcpToolDetailed<T = unknown>(
445
+ toolName: string,
446
+ args: Record<string, unknown>,
447
+ timeoutMs: number = 10000,
448
+ ): Promise<MctToolCallResult<T>> {
449
+ const mcpUrl = resolveMcpUrl();
450
+ const token = resolveAuthToken();
451
+
452
+ const payload = JSON.stringify({
453
+ jsonrpc: '2.0',
454
+ id: 1,
455
+ method: 'tools/call',
456
+ params: {
457
+ name: toolName,
458
+ arguments: args,
459
+ },
460
+ });
461
+
462
+ const headers: Record<string, string> = {
463
+ 'Content-Type': 'application/json',
464
+ Accept: 'application/json, text/event-stream',
465
+ };
466
+ if (token) {
467
+ headers['Authorization'] = `Bearer ${token}`;
468
+ }
469
+
470
+ const controller = new AbortController();
471
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
472
+
473
+ try {
474
+ const response = await fetch(mcpUrl, {
475
+ method: 'POST',
476
+ headers,
477
+ body: payload,
478
+ signal: controller.signal,
479
+ });
480
+
481
+ const text = await response.text();
482
+
483
+ if (
484
+ text.includes('JWT token is required') ||
485
+ text.includes('token is required') ||
486
+ text.includes('Unauthorized')
487
+ ) {
488
+ return {
489
+ data: null,
490
+ error: {
491
+ reason: 'auth',
492
+ detail: token
493
+ ? 'Auth token was sent but rejected by the server.'
494
+ : 'No auth token was found for the MCP request.',
495
+ status: response.status,
496
+ },
497
+ rawText: text,
498
+ };
499
+ }
500
+
501
+ if (!response.ok) {
502
+ return {
503
+ data: null,
504
+ error: {
505
+ reason: 'http_error',
506
+ detail: `HTTP ${response.status}: ${text.slice(0, 200)}`,
507
+ status: response.status,
508
+ },
509
+ rawText: text,
510
+ };
511
+ }
512
+
513
+ // Parse SSE response — look for data: lines
514
+ for (const line of text.split('\n')) {
515
+ if (line.startsWith('data: ')) {
516
+ try {
517
+ const d = JSON.parse(line.slice(6));
518
+ const errorText = d?.result?.content?.[0]?.text;
519
+ if (errorText && d?.result?.isError) {
520
+ return {
521
+ data: null,
522
+ error: {
523
+ reason: 'mcp_error',
524
+ detail: errorText,
525
+ status: response.status,
526
+ },
527
+ rawText: text,
528
+ };
529
+ }
530
+ const content = d?.result?.content?.[0]?.text;
531
+ if (content && !d?.result?.isError) {
532
+ return {
533
+ data: JSON.parse(content) as T,
534
+ error: null,
535
+ rawText: text,
536
+ };
537
+ }
538
+ } catch {
539
+ continue;
540
+ }
541
+ }
542
+ }
543
+
544
+ // Try direct JSON response (non-SSE)
545
+ try {
546
+ const direct = JSON.parse(text);
547
+ const errorText = direct?.result?.content?.[0]?.text;
548
+ if (errorText && direct?.result?.isError) {
549
+ return {
550
+ data: null,
551
+ error: {
552
+ reason: 'mcp_error',
553
+ detail: errorText,
554
+ status: response.status,
555
+ },
556
+ rawText: text,
557
+ };
558
+ }
559
+ const content = direct?.result?.content?.[0]?.text;
560
+ if (content && !direct?.result?.isError) {
561
+ return {
562
+ data: JSON.parse(content) as T,
563
+ error: null,
564
+ rawText: text,
565
+ };
566
+ }
567
+ } catch {
568
+ // Not parseable
569
+ }
570
+
571
+ return {
572
+ data: null,
573
+ error: {
574
+ reason: 'parse_error',
575
+ detail: `Could not parse response from ${mcpUrl}.`,
576
+ },
577
+ rawText: text,
578
+ };
579
+ } catch (error: unknown) {
580
+ if (error instanceof DOMException && error.name === 'AbortError') {
581
+ return {
582
+ data: null,
583
+ error: {
584
+ reason: 'timeout',
585
+ detail: `Request to ${mcpUrl} timed out after ${timeoutMs}ms.`,
586
+ },
587
+ rawText: null,
588
+ };
589
+ }
590
+
591
+ const message = error instanceof Error ? error.message : String(error);
592
+ const reason =
593
+ error instanceof TypeError ||
594
+ /unable to connect|fetch failed|network|econnrefused|enotfound/i.test(message)
595
+ ? 'network_error'
596
+ : 'unknown';
597
+ return {
598
+ data: null,
599
+ error: {
600
+ reason,
601
+ detail: message,
602
+ },
603
+ rawText: null,
604
+ };
605
+ } finally {
606
+ clearTimeout(timeout);
607
+ }
608
+ }
609
+
610
+ /**
611
+ * Call MCP tool and return raw SSE inner JSON text (unparsed inner).
612
+ * Useful when you need to grep specific fields from the response.
613
+ */
614
+ export async function callMcpToolRaw(
615
+ toolName: string,
616
+ args: Record<string, unknown>,
617
+ timeoutMs: number = 10000,
618
+ ): Promise<string | null> {
619
+ const result = await callMcpToolDetailed(toolName, args, timeoutMs);
620
+ if (result.data !== null) {
621
+ return JSON.stringify(result.data);
622
+ }
623
+ return null;
624
+ }
625
+
626
+ // ── Health Check ──
627
+
628
+ /** Check gramatr server health. Returns true if reachable. */
629
+ export async function checkServerHealth(timeoutMs: number = 5000): Promise<{ healthy: boolean; detail: string }> {
630
+ const mcpUrl = resolveMcpUrl();
631
+ const baseUrl = mcpUrl.replace(/\/mcp$/, '');
632
+
633
+ const controller = new AbortController();
634
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
635
+
636
+ try {
637
+ const response = await fetch(`${baseUrl}/health`, {
638
+ signal: controller.signal,
639
+ });
640
+
641
+ if (response.ok) {
642
+ const body = await response.text();
643
+ return { healthy: true, detail: body };
644
+ } else if (response.status === 404 || response.status === 405) {
645
+ return { healthy: true, detail: 'REACHABLE (no /health endpoint)' };
646
+ } else {
647
+ return { healthy: false, detail: `HTTP ${response.status}` };
648
+ }
649
+ } catch {
650
+ return { healthy: false, detail: `Cannot reach ${baseUrl}` };
651
+ } finally {
652
+ clearTimeout(timeout);
653
+ }
654
+ }
655
+
656
+ // ── File I/O Helpers ──
657
+
658
+ /** Read a JSON file. Returns null on failure. */
659
+ export function readJsonFile<T = unknown>(filePath: string): T | null {
660
+ try {
661
+ if (!existsSync(filePath)) return null;
662
+ return JSON.parse(readFileSync(filePath, 'utf8')) as T;
663
+ } catch {
664
+ return null;
665
+ }
666
+ }
667
+
668
+ /** Write JSON file (non-atomic, for temp files). */
669
+ export function writeJsonFile(filePath: string, data: unknown): void {
670
+ const dir = filePath.substring(0, filePath.lastIndexOf('/'));
671
+ if (dir && !existsSync(dir)) {
672
+ mkdirSync(dir, { recursive: true });
673
+ }
674
+ writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8');
675
+ }
676
+
677
+ /** Append a line to a file */
678
+ export function appendLine(filePath: string, line: string): void {
679
+ const dir = filePath.substring(0, filePath.lastIndexOf('/'));
680
+ if (dir && !existsSync(dir)) {
681
+ mkdirSync(dir, { recursive: true });
682
+ }
683
+ const { appendFileSync } = require('fs');
684
+ appendFileSync(filePath, line + '\n', 'utf8');
685
+ }
686
+
687
+ // ── Git Helpers ──
688
+
689
+ /** Get commit count since a given timestamp */
690
+ export function getCommitCountSince(since: string): number {
691
+ try {
692
+ const output = execSync(`git log --since="${since}" --oneline`, {
693
+ encoding: 'utf8',
694
+ stdio: ['pipe', 'pipe', 'pipe'],
695
+ }).trim();
696
+ if (!output) return 0;
697
+ return output.split('\n').length;
698
+ } catch {
699
+ return 0;
700
+ }
701
+ }
702
+
703
+ /** Get formatted commit log since a timestamp */
704
+ export function getCommitLogSince(since: string, maxCount: number = 5): string[] {
705
+ try {
706
+ const output = execSync(
707
+ `git log --since="${since}" --pretty=format:"%h - %s (%ar)" -${maxCount}`,
708
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
709
+ ).trim();
710
+ if (!output) return [];
711
+ return output.split('\n');
712
+ } catch {
713
+ return [];
714
+ }
715
+ }
716
+
717
+ /** Get short commit log (hash + subject) since a timestamp */
718
+ export function getCommitsSince(since: string, maxCount: number = 5): string[] {
719
+ try {
720
+ const output = execSync(
721
+ `git log --since="${since}" --pretty=format:"%h %s" -${maxCount}`,
722
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] },
723
+ ).trim();
724
+ if (!output) return [];
725
+ return output.split('\n');
726
+ } catch {
727
+ return [];
728
+ }
729
+ }
730
+
731
+ /** Get git status --short output */
732
+ export function getGitStatusShort(maxLines: number = 10): string[] {
733
+ try {
734
+ const output = execSync('git status --short', {
735
+ encoding: 'utf8',
736
+ stdio: ['pipe', 'pipe', 'pipe'],
737
+ }).trim();
738
+ if (!output) return [];
739
+ return output.split('\n').slice(0, maxLines);
740
+ } catch {
741
+ return [];
742
+ }
743
+ }
744
+
745
+ /** Get files changed between two refs */
746
+ export function getFilesChanged(fromRef: string, toRef: string = 'HEAD', maxCount: number = 10): string[] {
747
+ try {
748
+ const output = execSync(`git diff --name-only ${fromRef}..${toRef}`, {
749
+ encoding: 'utf8',
750
+ stdio: ['pipe', 'pipe', 'pipe'],
751
+ }).trim();
752
+ if (!output) return [];
753
+ return output.split('\n').slice(0, maxCount);
754
+ } catch {
755
+ return [];
756
+ }
757
+ }
758
+
759
+ /** Get recent commit log (formatted for display) */
760
+ export function getRecentCommits(count: number = 3): string[] {
761
+ try {
762
+ const output = execSync(`git log --oneline -${count}`, {
763
+ encoding: 'utf8',
764
+ stdio: ['pipe', 'pipe', 'pipe'],
765
+ }).trim();
766
+ if (!output) return [];
767
+ return output.split('\n');
768
+ } catch {
769
+ return [];
770
+ }
771
+ }