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 +55 -9
- package/bin/install.test.js +214 -0
- package/hooks/df-command-usage.js +287 -0
- package/hooks/df-command-usage.test.js +1019 -0
- package/hooks/df-subagent-registry.js +33 -14
- package/hooks/df-tool-usage.js +8 -0
- package/hooks/df-tool-usage.test.js +200 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
}
|
package/bin/install.test.js
CHANGED
|
@@ -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
|
+
}
|