deepflow 0.1.102 → 0.1.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.js CHANGED
@@ -256,6 +256,7 @@ async function configureHooks(claudeDir) {
256
256
  const snapshotGuardCmd = `node "${path.join(claudeDir, 'hooks', 'df-snapshot-guard.js')}"`;
257
257
  const invariantCheckCmd = `node "${path.join(claudeDir, 'hooks', 'df-invariant-check.js')}"`;
258
258
  const subagentRegistryCmd = `node "${path.join(claudeDir, 'hooks', 'df-subagent-registry.js')}"`;
259
+ const commandUsageCmd = `node "${path.join(claudeDir, 'hooks', 'df-command-usage.js')}"`;
259
260
 
260
261
  let settings = {};
261
262
 
@@ -333,10 +334,10 @@ async function configureHooks(claudeDir) {
333
334
  settings.hooks.SessionEnd = [];
334
335
  }
335
336
 
336
- // Remove any existing quota logger / dashboard push from SessionEnd
337
+ // Remove any existing quota logger / dashboard push / command usage from SessionEnd
337
338
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
338
339
  const cmd = hook.hooks?.[0]?.command || '';
339
- return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
340
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push') && !cmd.includes('df-command-usage');
340
341
  });
341
342
 
342
343
  // Add quota logger to SessionEnd
@@ -354,17 +355,25 @@ async function configureHooks(claudeDir) {
354
355
  command: dashboardPushCmd
355
356
  }]
356
357
  });
357
- log('Quota logger + dashboard push configured (SessionEnd)');
358
+
359
+ // Add command usage hook to SessionEnd (flush any pending command data)
360
+ settings.hooks.SessionEnd.push({
361
+ hooks: [{
362
+ type: 'command',
363
+ command: commandUsageCmd
364
+ }]
365
+ });
366
+ log('Quota logger + dashboard push + command usage configured (SessionEnd)');
358
367
 
359
368
  // Configure PostToolUse hook for tool usage instrumentation
360
369
  if (!settings.hooks.PostToolUse) {
361
370
  settings.hooks.PostToolUse = [];
362
371
  }
363
372
 
364
- // Remove any existing deepflow tool usage / execution history / worktree guard / snapshot guard / invariant check hooks from PostToolUse
373
+ // Remove any existing deepflow tool usage / execution history / worktree guard / snapshot guard / invariant check / command usage hooks from PostToolUse
365
374
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
366
375
  const cmd = hook.hooks?.[0]?.command || '';
367
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
376
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check') && !cmd.includes('df-command-usage');
368
377
  });
369
378
 
370
379
  // Add tool usage hook
@@ -406,6 +415,14 @@ async function configureHooks(claudeDir) {
406
415
  command: invariantCheckCmd
407
416
  }]
408
417
  });
418
+
419
+ // Add command usage hook to PostToolUse
420
+ settings.hooks.PostToolUse.push({
421
+ hooks: [{
422
+ type: 'command',
423
+ command: commandUsageCmd
424
+ }]
425
+ });
409
426
  log('PostToolUse hook configured');
410
427
 
411
428
  // Configure SubagentStop hook for subagent registry
@@ -428,6 +445,26 @@ async function configureHooks(claudeDir) {
428
445
  });
429
446
  log('SubagentStop hook configured');
430
447
 
448
+ // Configure PreToolUse hook for command usage instrumentation
449
+ if (!settings.hooks.PreToolUse) {
450
+ settings.hooks.PreToolUse = [];
451
+ }
452
+
453
+ // Remove any existing deepflow command usage hooks from PreToolUse
454
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
455
+ const cmd = hook.hooks?.[0]?.command || '';
456
+ return !cmd.includes('df-command-usage');
457
+ });
458
+
459
+ // Add command usage hook to PreToolUse
460
+ settings.hooks.PreToolUse.push({
461
+ hooks: [{
462
+ type: 'command',
463
+ command: commandUsageCmd
464
+ }]
465
+ });
466
+ log('PreToolUse hook configured');
467
+
431
468
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
432
469
  }
433
470
 
@@ -611,7 +648,7 @@ async function uninstall() {
611
648
  ];
612
649
 
613
650
  if (level === 'global') {
614
- toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js', 'hooks/df-snapshot-guard.js', 'hooks/df-subagent-registry.js');
651
+ toRemove.push('hooks/df-statusline.js', 'hooks/df-check-update.js', 'hooks/df-invariant-check.js', 'hooks/df-quota-logger.js', 'hooks/df-tool-usage.js', 'hooks/df-dashboard-push.js', 'hooks/df-execution-history.js', 'hooks/df-worktree-guard.js', 'hooks/df-snapshot-guard.js', 'hooks/df-subagent-registry.js', 'hooks/df-command-usage.js');
615
652
  }
616
653
 
617
654
  for (const item of toRemove) {
@@ -649,7 +686,7 @@ async function uninstall() {
649
686
  if (settings.hooks?.SessionEnd) {
650
687
  settings.hooks.SessionEnd = settings.hooks.SessionEnd.filter(hook => {
651
688
  const cmd = hook.hooks?.[0]?.command || '';
652
- return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push');
689
+ return !cmd.includes('df-quota-logger') && !cmd.includes('df-dashboard-push') && !cmd.includes('df-command-usage');
653
690
  });
654
691
  if (settings.hooks.SessionEnd.length === 0) {
655
692
  delete settings.hooks.SessionEnd;
@@ -658,12 +695,21 @@ async function uninstall() {
658
695
  if (settings.hooks?.PostToolUse) {
659
696
  settings.hooks.PostToolUse = settings.hooks.PostToolUse.filter(hook => {
660
697
  const cmd = hook.hooks?.[0]?.command || '';
661
- return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check');
698
+ return !cmd.includes('df-tool-usage') && !cmd.includes('df-execution-history') && !cmd.includes('df-worktree-guard') && !cmd.includes('df-snapshot-guard') && !cmd.includes('df-invariant-check') && !cmd.includes('df-command-usage');
662
699
  });
663
700
  if (settings.hooks.PostToolUse.length === 0) {
664
701
  delete settings.hooks.PostToolUse;
665
702
  }
666
703
  }
704
+ if (settings.hooks?.PreToolUse) {
705
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
706
+ const cmd = hook.hooks?.[0]?.command || '';
707
+ return !cmd.includes('df-command-usage');
708
+ });
709
+ if (settings.hooks.PreToolUse.length === 0) {
710
+ delete settings.hooks.PreToolUse;
711
+ }
712
+ }
667
713
  if (settings.hooks?.SubagentStop) {
668
714
  settings.hooks.SubagentStop = settings.hooks.SubagentStop.filter(hook => {
669
715
  const cmd = hook.hooks?.[0]?.command || '';
@@ -677,7 +723,7 @@ async function uninstall() {
677
723
  delete settings.hooks;
678
724
  }
679
725
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
680
- console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd/PostToolUse/SubagentStop hooks`);
726
+ console.log(` ${c.green}✓${c.reset} Removed SessionStart/SessionEnd/PreToolUse/PostToolUse/SubagentStop hooks`);
681
727
  } catch (e) {
682
728
  // Fail silently
683
729
  }
@@ -589,6 +589,220 @@ describe('Uninstaller — file removal and settings cleanup', () => {
589
589
  });
590
590
  });
591
591
 
592
+ // ---------------------------------------------------------------------------
593
+ // T4. command-usage hook registration (PreToolUse, PostToolUse, SessionEnd)
594
+ // ---------------------------------------------------------------------------
595
+
596
+ describe('T4 — command-usage hook registration in install.js', () => {
597
+
598
+ // -- Source-level checks: verify install.js registers df-command-usage.js --
599
+
600
+ test('source defines commandUsageCmd variable', () => {
601
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
602
+ const pattern = /commandUsageCmd\s*=\s*`node.*df-command-usage\.js/;
603
+ assert.ok(
604
+ pattern.test(src),
605
+ 'install.js should define commandUsageCmd pointing to df-command-usage.js'
606
+ );
607
+ });
608
+
609
+ test('source pushes command-usage hook to PreToolUse', () => {
610
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
611
+ // Find PreToolUse section — should contain a push with commandUsageCmd
612
+ const preToolUseSection = src.match(/PreToolUse[\s\S]*?log\('PreToolUse hook configured'\)/);
613
+ assert.ok(preToolUseSection, 'Should have a PreToolUse configuration section');
614
+ assert.ok(
615
+ preToolUseSection[0].includes('commandUsageCmd'),
616
+ 'PreToolUse section should push commandUsageCmd'
617
+ );
618
+ });
619
+
620
+ test('source pushes command-usage hook to PostToolUse', () => {
621
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
622
+ // Find PostToolUse section
623
+ const postToolUseSection = src.match(/PostToolUse[\s\S]*?log\('PostToolUse hook configured'\)/);
624
+ assert.ok(postToolUseSection, 'Should have a PostToolUse configuration section');
625
+ assert.ok(
626
+ postToolUseSection[0].includes('commandUsageCmd'),
627
+ 'PostToolUse section should push commandUsageCmd'
628
+ );
629
+ });
630
+
631
+ test('source pushes command-usage hook to SessionEnd', () => {
632
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
633
+ // Find SessionEnd section — should include command-usage alongside quota-logger + dashboard-push
634
+ const sessionEndSection = src.match(/SessionEnd[\s\S]*?log\('Quota logger.*configured.*SessionEnd/);
635
+ assert.ok(sessionEndSection, 'Should have a SessionEnd configuration section');
636
+ assert.ok(
637
+ sessionEndSection[0].includes('commandUsageCmd'),
638
+ 'SessionEnd section should push commandUsageCmd'
639
+ );
640
+ });
641
+
642
+ test('source creates PreToolUse array if missing', () => {
643
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
644
+ assert.ok(
645
+ src.includes("if (!settings.hooks.PreToolUse)"),
646
+ 'install.js should initialize PreToolUse array if not present'
647
+ );
648
+ });
649
+
650
+ // -- Dedup logic: filter removes existing command-usage before re-adding --
651
+
652
+ test('PreToolUse dedup filter removes existing df-command-usage entries', () => {
653
+ const preToolUse = [
654
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
655
+ { hooks: [{ type: 'command', command: 'node /usr/local/my-custom.js' }] },
656
+ ];
657
+
658
+ const filtered = preToolUse.filter(hook => {
659
+ const cmd = hook.hooks?.[0]?.command || '';
660
+ return !cmd.includes('df-command-usage');
661
+ });
662
+
663
+ assert.equal(filtered.length, 1, 'Should remove existing df-command-usage hook');
664
+ assert.ok(filtered[0].hooks[0].command.includes('my-custom.js'), 'Should keep non-deepflow hooks');
665
+ });
666
+
667
+ test('PostToolUse dedup filter removes df-command-usage alongside other deepflow hooks', () => {
668
+ const postToolUse = [
669
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-tool-usage.js' }] },
670
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
671
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-worktree-guard.js' }] },
672
+ { hooks: [{ type: 'command', command: 'node /usr/local/keep-me.js' }] },
673
+ ];
674
+
675
+ const filtered = postToolUse.filter(hook => {
676
+ const cmd = hook.hooks?.[0]?.command || '';
677
+ return !cmd.includes('df-tool-usage') &&
678
+ !cmd.includes('df-execution-history') &&
679
+ !cmd.includes('df-worktree-guard') &&
680
+ !cmd.includes('df-snapshot-guard') &&
681
+ !cmd.includes('df-invariant-check') &&
682
+ !cmd.includes('df-command-usage');
683
+ });
684
+
685
+ assert.equal(filtered.length, 1);
686
+ assert.ok(filtered[0].hooks[0].command.includes('keep-me.js'));
687
+ });
688
+
689
+ test('SessionEnd dedup filter removes df-command-usage alongside quota-logger and dashboard-push', () => {
690
+ const sessionEnd = [
691
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-quota-logger.js' }] },
692
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-dashboard-push.js' }] },
693
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
694
+ { hooks: [{ type: 'command', command: 'node /usr/local/keep.js' }] },
695
+ ];
696
+
697
+ const filtered = sessionEnd.filter(hook => {
698
+ const cmd = hook.hooks?.[0]?.command || '';
699
+ return !cmd.includes('df-quota-logger') &&
700
+ !cmd.includes('df-dashboard-push') &&
701
+ !cmd.includes('df-command-usage');
702
+ });
703
+
704
+ assert.equal(filtered.length, 1);
705
+ assert.ok(filtered[0].hooks[0].command.includes('keep.js'));
706
+ });
707
+
708
+ // -- Uninstall cleanup --
709
+
710
+ test('uninstall toRemove includes df-command-usage.js', () => {
711
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
712
+ // Find the toRemove.push(...) line for hooks in uninstall
713
+ assert.ok(
714
+ src.includes("'hooks/df-command-usage.js'"),
715
+ 'toRemove should include hooks/df-command-usage.js for uninstall'
716
+ );
717
+ });
718
+
719
+ test('uninstall SessionEnd filter removes df-command-usage', () => {
720
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
721
+ // In the uninstall function, the SessionEnd filter should include df-command-usage
722
+ // Find the uninstall section's SessionEnd filter
723
+ const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
724
+ assert.ok(uninstallSection, 'Should have uninstall function');
725
+ // Check SessionEnd filter in uninstall includes command-usage
726
+ const sessionEndFilter = uninstallSection[0].match(/SessionEnd[\s\S]*?\.filter[\s\S]*?\);/);
727
+ assert.ok(sessionEndFilter, 'Should have SessionEnd filter in uninstall');
728
+ assert.ok(
729
+ sessionEndFilter[0].includes('df-command-usage'),
730
+ 'Uninstall SessionEnd filter should remove df-command-usage hooks'
731
+ );
732
+ });
733
+
734
+ test('uninstall PostToolUse filter removes df-command-usage', () => {
735
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
736
+ const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
737
+ const postToolUseFilter = uninstallSection[0].match(/PostToolUse[\s\S]*?\.filter[\s\S]*?\);/);
738
+ assert.ok(postToolUseFilter, 'Should have PostToolUse filter in uninstall');
739
+ assert.ok(
740
+ postToolUseFilter[0].includes('df-command-usage'),
741
+ 'Uninstall PostToolUse filter should remove df-command-usage hooks'
742
+ );
743
+ });
744
+
745
+ test('uninstall cleans up PreToolUse hooks', () => {
746
+ const src = fs.readFileSync(path.resolve(__dirname, 'install.js'), 'utf8');
747
+ const uninstallSection = src.match(/async function uninstall[\s\S]+$/);
748
+ assert.ok(
749
+ uninstallSection[0].includes('PreToolUse'),
750
+ 'Uninstall function should handle PreToolUse cleanup'
751
+ );
752
+ // Verify it filters out df-command-usage from PreToolUse
753
+ const preToolUseFilter = uninstallSection[0].match(/PreToolUse[\s\S]*?\.filter[\s\S]*?\);/);
754
+ assert.ok(preToolUseFilter, 'Should have PreToolUse filter in uninstall');
755
+ assert.ok(
756
+ preToolUseFilter[0].includes('df-command-usage'),
757
+ 'Uninstall PreToolUse filter should remove df-command-usage hooks'
758
+ );
759
+ });
760
+
761
+ test('uninstall deletes PreToolUse key when array becomes empty', () => {
762
+ // Reproduce the uninstall logic for PreToolUse
763
+ const settings = {
764
+ hooks: {
765
+ PreToolUse: [
766
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
767
+ ],
768
+ }
769
+ };
770
+
771
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
772
+ const cmd = hook.hooks?.[0]?.command || '';
773
+ return !cmd.includes('df-command-usage');
774
+ });
775
+ if (settings.hooks.PreToolUse.length === 0) {
776
+ delete settings.hooks.PreToolUse;
777
+ }
778
+
779
+ assert.ok(!('PreToolUse' in settings.hooks), 'PreToolUse should be deleted when empty after filtering');
780
+ });
781
+
782
+ test('uninstall keeps PreToolUse when non-deepflow hooks remain', () => {
783
+ const settings = {
784
+ hooks: {
785
+ PreToolUse: [
786
+ { hooks: [{ type: 'command', command: 'node /home/.claude/hooks/df-command-usage.js' }] },
787
+ { hooks: [{ type: 'command', command: 'node /usr/local/custom-pre-hook.js' }] },
788
+ ],
789
+ }
790
+ };
791
+
792
+ settings.hooks.PreToolUse = settings.hooks.PreToolUse.filter(hook => {
793
+ const cmd = hook.hooks?.[0]?.command || '';
794
+ return !cmd.includes('df-command-usage');
795
+ });
796
+ if (settings.hooks.PreToolUse.length === 0) {
797
+ delete settings.hooks.PreToolUse;
798
+ }
799
+
800
+ assert.ok('PreToolUse' in settings.hooks, 'PreToolUse should be kept when custom hooks remain');
801
+ assert.equal(settings.hooks.PreToolUse.length, 1);
802
+ assert.ok(settings.hooks.PreToolUse[0].hooks[0].command.includes('custom-pre-hook.js'));
803
+ });
804
+ });
805
+
592
806
  // ---------------------------------------------------------------------------
593
807
  // 4. isInstalled helper logic
594
808
  // ---------------------------------------------------------------------------
@@ -0,0 +1,287 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * deepflow command usage tracker
4
+ * Tracks df:* command invocations with token deltas and tool call counts.
5
+ *
6
+ * Events:
7
+ * PreToolUse — detect Skill calls matching df:*, close previous command, open new marker
8
+ * PostToolUse — increment tool_calls_count on the active marker
9
+ * SessionEnd — close any open marker so the last command gets a record
10
+ *
11
+ * Marker: .deepflow/active-command.json
12
+ * Output: .deepflow/command-usage.jsonl (append-only)
13
+ */
14
+
15
+ 'use strict';
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const event = process.env.CLAUDE_HOOK_EVENT || '';
21
+
22
+ // Read stdin for hook payload
23
+ let raw = '';
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('data', d => raw += d);
26
+ process.stdin.on('end', () => {
27
+ try {
28
+ main();
29
+ } catch (_e) {
30
+ // REQ-8: never break Claude Code
31
+ }
32
+ process.exit(0);
33
+ });
34
+
35
+ function main() {
36
+ const baseDir = findProjectDir();
37
+ if (!baseDir) return;
38
+
39
+ const deepflowDir = path.join(baseDir, '.deepflow');
40
+ const markerPath = path.join(deepflowDir, 'active-command.json');
41
+ const usagePath = path.join(deepflowDir, 'command-usage.jsonl');
42
+ const tokenHistoryPath = path.join(deepflowDir, 'token-history.jsonl');
43
+
44
+ if (event === 'PreToolUse') {
45
+ handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath);
46
+ } else if (event === 'PostToolUse') {
47
+ handlePostToolUse(markerPath);
48
+ } else if (event === 'SessionEnd') {
49
+ handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath);
50
+ }
51
+ }
52
+
53
+ function handlePreToolUse(deepflowDir, markerPath, usagePath, tokenHistoryPath) {
54
+ let payload;
55
+ try { payload = JSON.parse(raw); } catch { return; }
56
+
57
+ const toolName = payload.tool_name || '';
58
+ const toolInput = payload.tool_input || {};
59
+
60
+ // Only trigger on Skill calls with df:* skill names
61
+ if (toolName !== 'Skill') return;
62
+ const skillName = toolInput.skill || '';
63
+ if (!skillName.startsWith('df:')) return;
64
+
65
+ const sessionId = payload.session_id || process.env.CLAUDE_SESSION_ID || 'unknown';
66
+
67
+ // If marker exists, close previous command first (close-on-next)
68
+ if (safeExists(markerPath)) {
69
+ closeCommand(markerPath, usagePath, tokenHistoryPath);
70
+ }
71
+
72
+ // Create new marker
73
+ ensureDir(deepflowDir);
74
+ const tokenSnapshot = readLastTokenRecord(tokenHistoryPath);
75
+ const transcriptPath = findTranscriptPath(payload);
76
+ const transcriptOffset = safeFileSize(transcriptPath);
77
+
78
+ const marker = {
79
+ command: skillName,
80
+ session_id: sessionId,
81
+ started_at: new Date().toISOString(),
82
+ token_snapshot: {
83
+ input_tokens: tokenSnapshot.input_tokens || 0,
84
+ cache_read_input_tokens: tokenSnapshot.cache_read_input_tokens || 0,
85
+ cache_creation_input_tokens: tokenSnapshot.cache_creation_input_tokens || 0
86
+ },
87
+ transcript_path: transcriptPath,
88
+ transcript_offset: transcriptOffset,
89
+ tool_calls_count: 0
90
+ };
91
+
92
+ safeWriteFile(markerPath, JSON.stringify(marker, null, 2));
93
+ }
94
+
95
+ function handlePostToolUse(markerPath) {
96
+ if (!safeExists(markerPath)) return;
97
+
98
+ // Don't count the Skill call itself (the one that opened the marker)
99
+ let payload;
100
+ try { payload = JSON.parse(raw); } catch { return; }
101
+ const toolName = payload.tool_name || '';
102
+ const toolInput = payload.tool_input || {};
103
+ if (toolName === 'Skill' && (toolInput.skill || '').startsWith('df:')) return;
104
+
105
+ try {
106
+ const marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
107
+ marker.tool_calls_count = (marker.tool_calls_count || 0) + 1;
108
+ safeWriteFile(markerPath, JSON.stringify(marker, null, 2));
109
+ } catch (_e) {
110
+ // Marker may have been deleted mid-session (REQ-8)
111
+ }
112
+ }
113
+
114
+ function handleSessionEnd(deepflowDir, markerPath, usagePath, tokenHistoryPath) {
115
+ if (!safeExists(markerPath)) return;
116
+ closeCommand(markerPath, usagePath, tokenHistoryPath);
117
+ }
118
+
119
+ /**
120
+ * Close the active command: compute deltas, parse transcript for output_tokens,
121
+ * append usage record, delete marker.
122
+ */
123
+ function closeCommand(markerPath, usagePath, tokenHistoryPath) {
124
+ let marker;
125
+ try {
126
+ marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
127
+ } catch (_e) {
128
+ safeDelete(markerPath);
129
+ return;
130
+ }
131
+
132
+ const endSnapshot = readLastTokenRecord(tokenHistoryPath);
133
+ const startSnapshot = marker.token_snapshot || {};
134
+
135
+ // Compute token deltas
136
+ const deltaIn = Math.max(0, (endSnapshot.input_tokens || 0) - (startSnapshot.input_tokens || 0));
137
+ const deltaCacheRead = Math.max(0, (endSnapshot.cache_read_input_tokens || 0) - (startSnapshot.cache_read_input_tokens || 0));
138
+ const deltaCacheCreate = Math.max(0, (endSnapshot.cache_creation_input_tokens || 0) - (startSnapshot.cache_creation_input_tokens || 0));
139
+
140
+ // Parse transcript for output_tokens
141
+ const outputTokens = parseTranscriptOutputTokens(
142
+ marker.transcript_path,
143
+ marker.transcript_offset || 0
144
+ );
145
+
146
+ const record = {
147
+ command: marker.command,
148
+ session_id: marker.session_id,
149
+ started_at: marker.started_at,
150
+ ended_at: new Date().toISOString(),
151
+ tool_calls_count: marker.tool_calls_count || 0,
152
+ input_tokens_delta: deltaIn,
153
+ output_tokens: outputTokens,
154
+ cache_read_delta: deltaCacheRead,
155
+ cache_creation_delta: deltaCacheCreate
156
+ };
157
+
158
+ // Append to usage JSONL
159
+ ensureDir(path.dirname(usagePath));
160
+ try {
161
+ fs.appendFileSync(usagePath, JSON.stringify(record) + '\n');
162
+ } catch (_e) {
163
+ // REQ-8: fail silently
164
+ }
165
+
166
+ safeDelete(markerPath);
167
+ }
168
+
169
+ /**
170
+ * Read the last line of token-history.jsonl by seeking the last ~2KB.
171
+ */
172
+ function readLastTokenRecord(tokenHistoryPath) {
173
+ try {
174
+ if (!fs.existsSync(tokenHistoryPath)) return {};
175
+ const stat = fs.statSync(tokenHistoryPath);
176
+ if (stat.size === 0) return {};
177
+
178
+ const readSize = Math.min(stat.size, 2048);
179
+ const buf = Buffer.alloc(readSize);
180
+ const fd = fs.openSync(tokenHistoryPath, 'r');
181
+ fs.readSync(fd, buf, 0, readSize, stat.size - readSize);
182
+ fs.closeSync(fd);
183
+
184
+ const chunk = buf.toString('utf8');
185
+ const lines = chunk.trimEnd().split('\n');
186
+ const lastLine = lines[lines.length - 1].trim();
187
+ if (!lastLine) return {};
188
+ return JSON.parse(lastLine);
189
+ } catch (_e) {
190
+ return {};
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Parse transcript from offset to current end, accumulating output_tokens
196
+ * from message.usage.output_tokens fields (pattern from df-subagent-registry.js).
197
+ */
198
+ function parseTranscriptOutputTokens(transcriptPath, offset) {
199
+ let total = 0;
200
+ try {
201
+ if (!transcriptPath || !fs.existsSync(transcriptPath)) return 0;
202
+ const stat = fs.statSync(transcriptPath);
203
+ if (stat.size <= offset) return 0;
204
+
205
+ const readLen = stat.size - offset;
206
+ const buf = Buffer.alloc(readLen);
207
+ const fd = fs.openSync(transcriptPath, 'r');
208
+ fs.readSync(fd, buf, 0, readLen, offset);
209
+ fs.closeSync(fd);
210
+
211
+ const slice = buf.toString('utf8');
212
+ const lines = slice.split('\n');
213
+ for (const line of lines) {
214
+ const trimmed = line.trim();
215
+ if (!trimmed) continue;
216
+ try {
217
+ const evt = JSON.parse(trimmed);
218
+ const usage = (evt.message && evt.message.usage) || evt.usage;
219
+ if (usage && usage.output_tokens) {
220
+ total += usage.output_tokens;
221
+ }
222
+ } catch (_e) {
223
+ // skip malformed lines
224
+ }
225
+ }
226
+ } catch (_e) {
227
+ // REQ-8
228
+ }
229
+ return total;
230
+ }
231
+
232
+ /**
233
+ * Find the project directory from hook payload or environment.
234
+ */
235
+ function findProjectDir() {
236
+ try {
237
+ const payload = JSON.parse(raw);
238
+ if (payload.cwd) return payload.cwd;
239
+ if (payload.workspace && payload.workspace.current_dir) return payload.workspace.current_dir;
240
+ } catch (_e) {
241
+ // fall through
242
+ }
243
+ return process.env.CLAUDE_PROJECT_DIR || process.cwd();
244
+ }
245
+
246
+ /**
247
+ * Find the transcript path from the hook payload.
248
+ */
249
+ function findTranscriptPath(payload) {
250
+ if (payload.transcript_path) return payload.transcript_path;
251
+ if (payload.session_storage_path) {
252
+ return path.join(payload.session_storage_path, 'transcript.jsonl');
253
+ }
254
+ return '';
255
+ }
256
+
257
+ // --- Utility helpers ---
258
+
259
+ function safeExists(filePath) {
260
+ try { return fs.existsSync(filePath); } catch { return false; }
261
+ }
262
+
263
+ function safeDelete(filePath) {
264
+ try { fs.unlinkSync(filePath); } catch (_e) { /* REQ-8 */ }
265
+ }
266
+
267
+ function safeWriteFile(filePath, data) {
268
+ try { fs.writeFileSync(filePath, data); } catch (_e) { /* REQ-8 */ }
269
+ }
270
+
271
+ function safeFileSize(filePath) {
272
+ try {
273
+ if (!filePath) return 0;
274
+ if (!fs.existsSync(filePath)) return 0;
275
+ return fs.statSync(filePath).size;
276
+ } catch (_e) {
277
+ return 0;
278
+ }
279
+ }
280
+
281
+ function ensureDir(dir) {
282
+ try {
283
+ if (!fs.existsSync(dir)) {
284
+ fs.mkdirSync(dir, { recursive: true });
285
+ }
286
+ } catch (_e) { /* REQ-8 */ }
287
+ }