@scheduler-systems/gal-run 0.0.406 → 0.0.408

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.
@@ -56,7 +56,7 @@ const cliVersion = cliPackageJson.version;
56
56
 
57
57
  // Version markers for idempotency checks
58
58
  // Bump these to force updates to installed files
59
- const HOOK_VERSION = '4.1.1'; // SessionStart hook (4.1.1: Fix dispatch backtick escaping in generated hook #4667)
59
+ const HOOK_VERSION = '4.2.0'; // SessionStart + Stop hooks with local usage reporting (#4673)
60
60
  const STATUS_LINE_VERSION = '1.0.0'; // Status line script
61
61
  const RULES_VERSION = '1.0.0'; // GAL CLI rules
62
62
 
@@ -114,10 +114,12 @@ const fs = require('fs');
114
114
  const path = require('path');
115
115
  const { execSync, spawn } = require('child_process');
116
116
  const os = require('os');
117
+ const crypto = require('crypto');
117
118
 
118
119
  const GAL_DIR = '.gal';
119
120
  const SYNC_STATE_FILE = 'sync-state.json';
120
121
  const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
122
+ const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
121
123
 
122
124
  function showMessage(message, status) {
123
125
  // Queue telemetry event before showing message
@@ -156,7 +158,7 @@ function queueTelemetryEvent(status) {
156
158
 
157
159
  // Add session start hook event
158
160
  pending.push({
159
- id: require('crypto').randomUUID(),
161
+ id: crypto.randomUUID(),
160
162
  eventType: 'hook_triggered',
161
163
  timestamp: new Date().toISOString(),
162
164
  payload: {
@@ -180,6 +182,45 @@ function queueTelemetryEvent(status) {
180
182
  }
181
183
  }
182
184
 
185
+ function readHookInput() {
186
+ try {
187
+ if (process.stdin.isTTY) return {};
188
+ const raw = fs.readFileSync(0, 'utf-8');
189
+ if (!raw || !raw.trim()) return {};
190
+ return JSON.parse(raw);
191
+ } catch {
192
+ return {};
193
+ }
194
+ }
195
+
196
+ function getSessionUsagePath(inputData) {
197
+ const identity =
198
+ (typeof inputData.session_id === 'string' && inputData.session_id) ||
199
+ (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
200
+ process.cwd();
201
+ const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
202
+ return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
203
+ }
204
+
205
+ function rememberSessionStart(inputData) {
206
+ try {
207
+ const filePath = getSessionUsagePath(inputData);
208
+ if (!fs.existsSync(SESSION_USAGE_DIR)) {
209
+ fs.mkdirSync(SESSION_USAGE_DIR, { recursive: true });
210
+ }
211
+ fs.writeFileSync(
212
+ filePath,
213
+ JSON.stringify({
214
+ sessionId: inputData.session_id || null,
215
+ transcriptPath: inputData.transcript_path || null,
216
+ source: inputData.source || null,
217
+ startedAt: new Date().toISOString(),
218
+ }),
219
+ 'utf-8',
220
+ );
221
+ } catch {}
222
+ }
223
+
183
224
  // =============================================================================
184
225
  // Self-cleaning: Remove hook if GAL CLI is uninstalled
185
226
  // =============================================================================
@@ -209,7 +250,7 @@ function selfClean() {
209
250
  try {
210
251
  if (fs.existsSync(settingsPath)) {
211
252
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
212
- const hookEvents = ['SessionStart', 'UserPromptSubmit'];
253
+ const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
213
254
 
214
255
  for (const event of hookEvents) {
215
256
  if (settings.hooks?.[event]) {
@@ -228,6 +269,9 @@ function selfClean() {
228
269
  } catch {}
229
270
  }
230
271
 
272
+ const hookInput = readHookInput();
273
+ rememberSessionStart(hookInput);
274
+
231
275
  // Check if GAL is installed, self-clean if not
232
276
  if (!isGalInstalled()) {
233
277
  selfClean();
@@ -423,6 +467,137 @@ try {
423
467
  showMessage(syncMessage, 'synced');
424
468
  `;
425
469
 
470
+ const STOP_HOOK_CONTENT = `#!/usr/bin/env node
471
+ /**
472
+ * GAL Local Usage Report Hook for Claude Code (Stop)
473
+ * Version: ${HOOK_VERSION}
474
+ */
475
+
476
+ // GAL_HOOK_VERSION = "${HOOK_VERSION}"
477
+
478
+ const fs = require('fs');
479
+ const path = require('path');
480
+ const os = require('os');
481
+ const crypto = require('crypto');
482
+ const { execSync } = require('child_process');
483
+
484
+ const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
485
+ const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
486
+
487
+ function readHookInput() {
488
+ try {
489
+ if (process.stdin.isTTY) return {};
490
+ const raw = fs.readFileSync(0, 'utf-8');
491
+ if (!raw || !raw.trim()) return {};
492
+ return JSON.parse(raw);
493
+ } catch {
494
+ return {};
495
+ }
496
+ }
497
+
498
+ function getSessionUsagePath(inputData) {
499
+ const identity =
500
+ (typeof inputData.session_id === 'string' && inputData.session_id) ||
501
+ (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
502
+ process.cwd();
503
+ const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
504
+ return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
505
+ }
506
+
507
+ function readGalConfig() {
508
+ if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
509
+ try {
510
+ return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
511
+ } catch { return null; }
512
+ }
513
+
514
+ function isGalInstalled() {
515
+ try {
516
+ execSync('which gal', { stdio: 'ignore' });
517
+ return true;
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+
523
+ function selfClean() {
524
+ const hookPath = __filename;
525
+ const claudeDir = path.join(os.homedir(), '.claude');
526
+ const settingsPath = path.join(claudeDir, 'settings.json');
527
+
528
+ try { fs.unlinkSync(hookPath); } catch {}
529
+
530
+ try {
531
+ if (fs.existsSync(settingsPath)) {
532
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
533
+ const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
534
+
535
+ for (const event of hookEvents) {
536
+ if (settings.hooks?.[event]) {
537
+ settings.hooks[event] = settings.hooks[event].filter(entry => {
538
+ if (!entry.hooks) return true;
539
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
540
+ return entry.hooks.length > 0;
541
+ });
542
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
543
+ }
544
+ }
545
+
546
+ if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
547
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
548
+ }
549
+ } catch {}
550
+ }
551
+
552
+ if (!isGalInstalled()) {
553
+ selfClean();
554
+ process.exit(0);
555
+ }
556
+
557
+ const hookInput = readHookInput();
558
+ const usagePath = getSessionUsagePath(hookInput);
559
+
560
+ if (!fs.existsSync(usagePath)) {
561
+ process.exit(0);
562
+ }
563
+
564
+ let startedAt = null;
565
+ try {
566
+ const sessionState = JSON.parse(fs.readFileSync(usagePath, 'utf-8'));
567
+ startedAt = sessionState.startedAt || null;
568
+ } catch {}
569
+
570
+ try {
571
+ fs.unlinkSync(usagePath);
572
+ } catch {}
573
+
574
+ if (!startedAt) {
575
+ process.exit(0);
576
+ }
577
+
578
+ const startedMs = new Date(startedAt).getTime();
579
+ if (!Number.isFinite(startedMs) || startedMs <= 0) {
580
+ process.exit(0);
581
+ }
582
+
583
+ const durationSeconds = Math.max(1, Math.round((Date.now() - startedMs) / 1000));
584
+ const galConfig = readGalConfig();
585
+ const orgName = galConfig?.defaultOrg;
586
+
587
+ if (!orgName) {
588
+ process.exit(0);
589
+ }
590
+
591
+ try {
592
+ execSync(
593
+ \`gal report-usage --provider claude --seconds \${durationSeconds} --org \${JSON.stringify(orgName)} --json\`,
594
+ { stdio: 'pipe', timeout: 15000, shell: true },
595
+ );
596
+ } catch {}
597
+
598
+ process.exit(0);
599
+ `;
600
+
426
601
  // =============================================================================
427
602
  // Status Line Script Content
428
603
  // =============================================================================
@@ -615,6 +790,7 @@ function installHook() {
615
790
  const claudeDir = path.join(os.homedir(), '.claude');
616
791
  const hooksDir = path.join(claudeDir, 'hooks');
617
792
  const hookPath = path.join(hooksDir, 'gal-sync-reminder.js');
793
+ const stopHookPath = path.join(hooksDir, 'gal-usage-report.js');
618
794
  const settingsPath = path.join(claudeDir, 'settings.json');
619
795
 
620
796
  try {
@@ -632,12 +808,24 @@ function installHook() {
632
808
  needsUpdate = false;
633
809
  }
634
810
  }
811
+ let needsStopUpdate = true;
812
+ if (fs.existsSync(stopHookPath)) {
813
+ const existingContent = fs.readFileSync(stopHookPath, 'utf-8');
814
+ const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
815
+ if (versionMatch && versionMatch[1] === HOOK_VERSION) {
816
+ needsStopUpdate = false;
817
+ }
818
+ }
635
819
 
636
820
  // Write the hook file if needed
637
821
  if (needsUpdate) {
638
822
  fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf-8');
639
823
  fs.chmodSync(hookPath, '755');
640
824
  }
825
+ if (needsStopUpdate) {
826
+ fs.writeFileSync(stopHookPath, STOP_HOOK_CONTENT, 'utf-8');
827
+ fs.chmodSync(stopHookPath, '755');
828
+ }
641
829
 
642
830
  // Update settings.json
643
831
  let settings = {};
@@ -669,22 +857,31 @@ function installHook() {
669
857
  const hookCommand = `node ${hookPath}`;
670
858
  if (!settings.hooks) settings.hooks = {};
671
859
  if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
860
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
672
861
 
673
862
  const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
674
863
  entry.hooks?.some(h => h.command?.includes('gal-sync-reminder'))
675
864
  );
865
+ const stopAlreadyRegistered = settings.hooks.Stop.some(entry =>
866
+ entry.hooks?.some(h => h.command?.includes('gal-usage-report'))
867
+ );
676
868
 
677
869
  if (!alreadyRegistered) {
678
870
  settings.hooks.SessionStart.push({
679
871
  hooks: [{ type: 'command', command: hookCommand }]
680
872
  });
681
873
  }
874
+ if (!stopAlreadyRegistered) {
875
+ settings.hooks.Stop.push({
876
+ hooks: [{ type: 'command', command: `node ${stopHookPath}` }]
877
+ });
878
+ }
682
879
 
683
880
  // Write settings
684
881
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
685
882
 
686
- if (needsUpdate) {
687
- console.log('✓ GAL SessionStart hook installed');
883
+ if (needsUpdate || needsStopUpdate) {
884
+ console.log('✓ GAL Claude hooks installed');
688
885
  }
689
886
  return true;
690
887
  } catch (error) {
@@ -45,7 +45,7 @@ const os = require('os');
45
45
  * This function surgically removes only GAL-specific hook entries while
46
46
  * preserving the user's other hooks and settings. It handles:
47
47
  * - Filtering GAL hooks from UserPromptSubmit array
48
- * - Filtering GAL hooks from SessionStart array (v2.x)
48
+ * - Filtering GAL hooks from SessionStart and Stop arrays
49
49
  * - Removing empty hook arrays after filtering
50
50
  * - Preserving the settings.json file structure
51
51
  *
@@ -60,24 +60,31 @@ function removeGalHookEntries(settingsPath) {
60
60
 
61
61
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
62
62
 
63
- if (!settings.hooks?.UserPromptSubmit) {
63
+ if (!settings.hooks) {
64
64
  return false;
65
65
  }
66
66
 
67
- // Filter out GAL hooks while preserving user's other hooks
68
- const originalLength = settings.hooks.UserPromptSubmit.length;
69
- settings.hooks.UserPromptSubmit = settings.hooks.UserPromptSubmit.filter((entry) => {
70
- if (!entry.hooks) return true;
71
- // Keep entry only if it has non-GAL hooks
72
- entry.hooks = entry.hooks.filter((hook) =>
73
- !hook.command?.includes('gal-') && !hook.command?.includes('/gal/')
74
- );
75
- return entry.hooks.length > 0;
76
- });
77
-
78
- // Remove empty hooks array
79
- if (settings.hooks.UserPromptSubmit.length === 0) {
80
- delete settings.hooks.UserPromptSubmit;
67
+ let removedAny = false;
68
+
69
+ for (const event of ['UserPromptSubmit', 'SessionStart', 'Stop']) {
70
+ if (!settings.hooks[event]) continue;
71
+
72
+ const originalLength = settings.hooks[event].length;
73
+ settings.hooks[event] = settings.hooks[event].filter((entry) => {
74
+ if (!entry.hooks) return true;
75
+ entry.hooks = entry.hooks.filter((hook) =>
76
+ !hook.command?.includes('gal-') && !hook.command?.includes('/gal/')
77
+ );
78
+ return entry.hooks.length > 0;
79
+ });
80
+
81
+ if (settings.hooks[event].length === 0) {
82
+ delete settings.hooks[event];
83
+ }
84
+
85
+ if ((settings.hooks[event]?.length || 0) !== originalLength) {
86
+ removedAny = true;
87
+ }
81
88
  }
82
89
 
83
90
  // Remove empty hooks object
@@ -85,7 +92,7 @@ function removeGalHookEntries(settingsPath) {
85
92
  delete settings.hooks;
86
93
  }
87
94
 
88
- if (settings.hooks?.UserPromptSubmit?.length !== originalLength) {
95
+ if (removedAny) {
89
96
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
90
97
  return true;
91
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scheduler-systems/gal-run",
3
- "version": "0.0.406",
3
+ "version": "0.0.408",
4
4
  "description": "GAL CLI - Command-line tool for managing AI agent configurations across your organization",
5
5
  "license": "Elastic-2.0",
6
6
  "private": false,
@@ -56,7 +56,7 @@ const cliVersion = cliPackageJson.version;
56
56
 
57
57
  // Version markers for idempotency checks
58
58
  // Bump these to force updates to installed files
59
- const HOOK_VERSION = '4.1.1'; // SessionStart hook (4.1.1: Fix dispatch backtick escaping in generated hook #4667)
59
+ const HOOK_VERSION = '4.2.0'; // SessionStart + Stop hooks with local usage reporting (#4673)
60
60
  const STATUS_LINE_VERSION = '1.0.0'; // Status line script
61
61
  const RULES_VERSION = '1.0.0'; // GAL CLI rules
62
62
 
@@ -114,10 +114,12 @@ const fs = require('fs');
114
114
  const path = require('path');
115
115
  const { execSync, spawn } = require('child_process');
116
116
  const os = require('os');
117
+ const crypto = require('crypto');
117
118
 
118
119
  const GAL_DIR = '.gal';
119
120
  const SYNC_STATE_FILE = 'sync-state.json';
120
121
  const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
122
+ const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
121
123
 
122
124
  function showMessage(message, status) {
123
125
  // Queue telemetry event before showing message
@@ -156,7 +158,7 @@ function queueTelemetryEvent(status) {
156
158
 
157
159
  // Add session start hook event
158
160
  pending.push({
159
- id: require('crypto').randomUUID(),
161
+ id: crypto.randomUUID(),
160
162
  eventType: 'hook_triggered',
161
163
  timestamp: new Date().toISOString(),
162
164
  payload: {
@@ -180,6 +182,45 @@ function queueTelemetryEvent(status) {
180
182
  }
181
183
  }
182
184
 
185
+ function readHookInput() {
186
+ try {
187
+ if (process.stdin.isTTY) return {};
188
+ const raw = fs.readFileSync(0, 'utf-8');
189
+ if (!raw || !raw.trim()) return {};
190
+ return JSON.parse(raw);
191
+ } catch {
192
+ return {};
193
+ }
194
+ }
195
+
196
+ function getSessionUsagePath(inputData) {
197
+ const identity =
198
+ (typeof inputData.session_id === 'string' && inputData.session_id) ||
199
+ (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
200
+ process.cwd();
201
+ const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
202
+ return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
203
+ }
204
+
205
+ function rememberSessionStart(inputData) {
206
+ try {
207
+ const filePath = getSessionUsagePath(inputData);
208
+ if (!fs.existsSync(SESSION_USAGE_DIR)) {
209
+ fs.mkdirSync(SESSION_USAGE_DIR, { recursive: true });
210
+ }
211
+ fs.writeFileSync(
212
+ filePath,
213
+ JSON.stringify({
214
+ sessionId: inputData.session_id || null,
215
+ transcriptPath: inputData.transcript_path || null,
216
+ source: inputData.source || null,
217
+ startedAt: new Date().toISOString(),
218
+ }),
219
+ 'utf-8',
220
+ );
221
+ } catch {}
222
+ }
223
+
183
224
  // =============================================================================
184
225
  // Self-cleaning: Remove hook if GAL CLI is uninstalled
185
226
  // =============================================================================
@@ -209,7 +250,7 @@ function selfClean() {
209
250
  try {
210
251
  if (fs.existsSync(settingsPath)) {
211
252
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
212
- const hookEvents = ['SessionStart', 'UserPromptSubmit'];
253
+ const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
213
254
 
214
255
  for (const event of hookEvents) {
215
256
  if (settings.hooks?.[event]) {
@@ -228,6 +269,9 @@ function selfClean() {
228
269
  } catch {}
229
270
  }
230
271
 
272
+ const hookInput = readHookInput();
273
+ rememberSessionStart(hookInput);
274
+
231
275
  // Check if GAL is installed, self-clean if not
232
276
  if (!isGalInstalled()) {
233
277
  selfClean();
@@ -423,6 +467,137 @@ try {
423
467
  showMessage(syncMessage, 'synced');
424
468
  `;
425
469
 
470
+ const STOP_HOOK_CONTENT = `#!/usr/bin/env node
471
+ /**
472
+ * GAL Local Usage Report Hook for Claude Code (Stop)
473
+ * Version: ${HOOK_VERSION}
474
+ */
475
+
476
+ // GAL_HOOK_VERSION = "${HOOK_VERSION}"
477
+
478
+ const fs = require('fs');
479
+ const path = require('path');
480
+ const os = require('os');
481
+ const crypto = require('crypto');
482
+ const { execSync } = require('child_process');
483
+
484
+ const SESSION_USAGE_DIR = path.join(os.homedir(), '.gal', 'claude-session-usage');
485
+ const GAL_CONFIG_FILE = path.join(os.homedir(), '.gal', 'config.json');
486
+
487
+ function readHookInput() {
488
+ try {
489
+ if (process.stdin.isTTY) return {};
490
+ const raw = fs.readFileSync(0, 'utf-8');
491
+ if (!raw || !raw.trim()) return {};
492
+ return JSON.parse(raw);
493
+ } catch {
494
+ return {};
495
+ }
496
+ }
497
+
498
+ function getSessionUsagePath(inputData) {
499
+ const identity =
500
+ (typeof inputData.session_id === 'string' && inputData.session_id) ||
501
+ (typeof inputData.transcript_path === 'string' && inputData.transcript_path) ||
502
+ process.cwd();
503
+ const sessionKey = crypto.createHash('sha256').update(identity).digest('hex');
504
+ return path.join(SESSION_USAGE_DIR, \`\${sessionKey}.json\`);
505
+ }
506
+
507
+ function readGalConfig() {
508
+ if (!fs.existsSync(GAL_CONFIG_FILE)) return null;
509
+ try {
510
+ return JSON.parse(fs.readFileSync(GAL_CONFIG_FILE, 'utf-8'));
511
+ } catch { return null; }
512
+ }
513
+
514
+ function isGalInstalled() {
515
+ try {
516
+ execSync('which gal', { stdio: 'ignore' });
517
+ return true;
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+
523
+ function selfClean() {
524
+ const hookPath = __filename;
525
+ const claudeDir = path.join(os.homedir(), '.claude');
526
+ const settingsPath = path.join(claudeDir, 'settings.json');
527
+
528
+ try { fs.unlinkSync(hookPath); } catch {}
529
+
530
+ try {
531
+ if (fs.existsSync(settingsPath)) {
532
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
533
+ const hookEvents = ['SessionStart', 'Stop', 'UserPromptSubmit'];
534
+
535
+ for (const event of hookEvents) {
536
+ if (settings.hooks?.[event]) {
537
+ settings.hooks[event] = settings.hooks[event].filter(entry => {
538
+ if (!entry.hooks) return true;
539
+ entry.hooks = entry.hooks.filter(h => !h.command?.includes('gal-'));
540
+ return entry.hooks.length > 0;
541
+ });
542
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
543
+ }
544
+ }
545
+
546
+ if (Object.keys(settings.hooks || {}).length === 0) delete settings.hooks;
547
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
548
+ }
549
+ } catch {}
550
+ }
551
+
552
+ if (!isGalInstalled()) {
553
+ selfClean();
554
+ process.exit(0);
555
+ }
556
+
557
+ const hookInput = readHookInput();
558
+ const usagePath = getSessionUsagePath(hookInput);
559
+
560
+ if (!fs.existsSync(usagePath)) {
561
+ process.exit(0);
562
+ }
563
+
564
+ let startedAt = null;
565
+ try {
566
+ const sessionState = JSON.parse(fs.readFileSync(usagePath, 'utf-8'));
567
+ startedAt = sessionState.startedAt || null;
568
+ } catch {}
569
+
570
+ try {
571
+ fs.unlinkSync(usagePath);
572
+ } catch {}
573
+
574
+ if (!startedAt) {
575
+ process.exit(0);
576
+ }
577
+
578
+ const startedMs = new Date(startedAt).getTime();
579
+ if (!Number.isFinite(startedMs) || startedMs <= 0) {
580
+ process.exit(0);
581
+ }
582
+
583
+ const durationSeconds = Math.max(1, Math.round((Date.now() - startedMs) / 1000));
584
+ const galConfig = readGalConfig();
585
+ const orgName = galConfig?.defaultOrg;
586
+
587
+ if (!orgName) {
588
+ process.exit(0);
589
+ }
590
+
591
+ try {
592
+ execSync(
593
+ \`gal report-usage --provider claude --seconds \${durationSeconds} --org \${JSON.stringify(orgName)} --json\`,
594
+ { stdio: 'pipe', timeout: 15000, shell: true },
595
+ );
596
+ } catch {}
597
+
598
+ process.exit(0);
599
+ `;
600
+
426
601
  // =============================================================================
427
602
  // Status Line Script Content
428
603
  // =============================================================================
@@ -615,6 +790,7 @@ function installHook() {
615
790
  const claudeDir = path.join(os.homedir(), '.claude');
616
791
  const hooksDir = path.join(claudeDir, 'hooks');
617
792
  const hookPath = path.join(hooksDir, 'gal-sync-reminder.js');
793
+ const stopHookPath = path.join(hooksDir, 'gal-usage-report.js');
618
794
  const settingsPath = path.join(claudeDir, 'settings.json');
619
795
 
620
796
  try {
@@ -632,12 +808,24 @@ function installHook() {
632
808
  needsUpdate = false;
633
809
  }
634
810
  }
811
+ let needsStopUpdate = true;
812
+ if (fs.existsSync(stopHookPath)) {
813
+ const existingContent = fs.readFileSync(stopHookPath, 'utf-8');
814
+ const versionMatch = existingContent.match(/GAL_HOOK_VERSION = "([^"]+)"/);
815
+ if (versionMatch && versionMatch[1] === HOOK_VERSION) {
816
+ needsStopUpdate = false;
817
+ }
818
+ }
635
819
 
636
820
  // Write the hook file if needed
637
821
  if (needsUpdate) {
638
822
  fs.writeFileSync(hookPath, HOOK_CONTENT, 'utf-8');
639
823
  fs.chmodSync(hookPath, '755');
640
824
  }
825
+ if (needsStopUpdate) {
826
+ fs.writeFileSync(stopHookPath, STOP_HOOK_CONTENT, 'utf-8');
827
+ fs.chmodSync(stopHookPath, '755');
828
+ }
641
829
 
642
830
  // Update settings.json
643
831
  let settings = {};
@@ -669,22 +857,31 @@ function installHook() {
669
857
  const hookCommand = `node ${hookPath}`;
670
858
  if (!settings.hooks) settings.hooks = {};
671
859
  if (!settings.hooks.SessionStart) settings.hooks.SessionStart = [];
860
+ if (!settings.hooks.Stop) settings.hooks.Stop = [];
672
861
 
673
862
  const alreadyRegistered = settings.hooks.SessionStart.some(entry =>
674
863
  entry.hooks?.some(h => h.command?.includes('gal-sync-reminder'))
675
864
  );
865
+ const stopAlreadyRegistered = settings.hooks.Stop.some(entry =>
866
+ entry.hooks?.some(h => h.command?.includes('gal-usage-report'))
867
+ );
676
868
 
677
869
  if (!alreadyRegistered) {
678
870
  settings.hooks.SessionStart.push({
679
871
  hooks: [{ type: 'command', command: hookCommand }]
680
872
  });
681
873
  }
874
+ if (!stopAlreadyRegistered) {
875
+ settings.hooks.Stop.push({
876
+ hooks: [{ type: 'command', command: `node ${stopHookPath}` }]
877
+ });
878
+ }
682
879
 
683
880
  // Write settings
684
881
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
685
882
 
686
- if (needsUpdate) {
687
- console.log('✓ GAL SessionStart hook installed');
883
+ if (needsUpdate || needsStopUpdate) {
884
+ console.log('✓ GAL Claude hooks installed');
688
885
  }
689
886
  return true;
690
887
  } catch (error) {