ai-lens 0.8.37 → 0.8.38

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/.commithash CHANGED
@@ -1 +1 @@
1
- 888a8ca
1
+ ae3a5cd
package/cli/hooks.js CHANGED
@@ -353,7 +353,13 @@ function isCurrentAiLensHook(entry, expected) {
353
353
  const norm = s => (s || '').replace(/\\/g, '/');
354
354
  // Flat format (Cursor)
355
355
  if (entry?.command != null) {
356
- if (isAiLensCapturePath(entry.command)) return true;
356
+ if (isAiLensCapturePath(entry.command)) {
357
+ // On Windows, Cursor hooks require & prefix for PowerShell invocation.
358
+ // If expected has & but entry doesn't, treat as outdated so init updates it.
359
+ const expectedCmd = expected?.command || '';
360
+ if (expectedCmd.startsWith('& ') && !entry.command.startsWith('& ')) return false;
361
+ return true;
362
+ }
357
363
  if (norm(entry.command) === norm(expected?.command || expected?.hooks?.[0]?.command || '')) return true;
358
364
  return false;
359
365
  }
@@ -705,6 +711,62 @@ export function removeCursorMcp() {
705
711
  }
706
712
  }
707
713
 
714
+ /**
715
+ * When --project-hooks is used, strip AI Lens hooks from the GLOBAL config
716
+ * to prevent double-firing (both global and project hooks triggering capture.js).
717
+ * Only handles project → global direction (global → project is skipped because
718
+ * the project path is unknown without --project-hooks context).
719
+ *
720
+ * @param {object[]} activeTools - The tool configs being written to (project or global).
721
+ * @returns {Array<{ path: string, action: string, toolName: string }>} - What was cleaned up.
722
+ */
723
+ export function cleanupOppositeScope(activeTools) {
724
+ const results = [];
725
+ for (const active of activeTools) {
726
+ // Determine the "opposite" config path
727
+ let oppositeConfigPath;
728
+ let oppositeToolName;
729
+ if (active.name.includes('(project)')) {
730
+ // Active is project → opposite is global
731
+ const global = TOOL_CONFIGS.find(t => active.name.startsWith(t.name));
732
+ if (!global) continue;
733
+ oppositeConfigPath = global.configPath;
734
+ oppositeToolName = global.name;
735
+ } else {
736
+ // Active is global → can't determine project path without cwd context, skip
737
+ continue;
738
+ }
739
+
740
+ if (!existsSync(oppositeConfigPath)) continue;
741
+
742
+ let raw;
743
+ try { raw = readFileSync(oppositeConfigPath, 'utf-8'); } catch { continue; }
744
+ let config;
745
+ try { config = JSON.parse(raw); } catch { continue; }
746
+
747
+ const hooks = config.hooks;
748
+ if (!hooks || typeof hooks !== 'object') continue;
749
+
750
+ let hasAiLens = false;
751
+ for (const entries of Object.values(hooks)) {
752
+ if (Array.isArray(entries) && entries.some(e => isAiLensHook(e))) {
753
+ hasAiLens = true;
754
+ break;
755
+ }
756
+ }
757
+ if (!hasAiLens) continue;
758
+
759
+ // Strip AI Lens hooks from global config
760
+ const stripped = buildStrippedConfig(active, config);
761
+ if (stripped) {
762
+ writeHooksConfig({ configPath: oppositeConfigPath }, stripped);
763
+ results.push({ path: oppositeConfigPath, action: 'cleaned', toolName: oppositeToolName });
764
+ }
765
+ // Don't delete the file — global settings.json may have other settings
766
+ }
767
+ return results;
768
+ }
769
+
708
770
  export function describePlan(tool, analysis) {
709
771
  const hookNames = Object.keys(tool.hookDefs);
710
772
 
package/cli/init.js CHANGED
@@ -17,7 +17,7 @@ import {
17
17
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan,
18
18
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
19
19
  getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath,
20
- cleanupLegacyHooks, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
20
+ cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
21
21
  checkHooksDisabled, enableHooks,
22
22
  } from './hooks.js';
23
23
 
@@ -576,6 +576,15 @@ export default async function init() {
576
576
  // non-fatal — hooks are current, legacy cleanup is best-effort
577
577
  }
578
578
  }
579
+ // Strip AI Lens hooks from opposite scope to prevent double-firing
580
+ try {
581
+ const allTools = analyses.map(a => a.tool);
582
+ for (const lr of cleanupOppositeScope(allTools)) {
583
+ success(` ${lr.toolName}: stripped AI Lens hooks from ${lr.path} (prevent duplicates)`);
584
+ }
585
+ } catch (err) {
586
+ // non-fatal
587
+ }
579
588
 
580
589
  success('Hooks are up-to-date.');
581
590
  } else {
@@ -648,6 +657,16 @@ export default async function init() {
648
657
  // non-fatal — hooks are verified, legacy cleanup is best-effort
649
658
  }
650
659
  }
660
+ // When --project-hooks: strip AI Lens hooks from global configs to prevent
661
+ // double-firing (both global and project hooks triggering capture.js).
662
+ try {
663
+ const allTools = analyses.map(a => a.tool);
664
+ for (const lr of cleanupOppositeScope(allTools)) {
665
+ success(` ${lr.toolName}: stripped AI Lens hooks from ${lr.path} (prevent duplicates)`);
666
+ }
667
+ } catch (err) {
668
+ // non-fatal
669
+ }
651
670
  }
652
671
 
653
672
  // Summary
package/client/capture.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync, writeFileSync, appendFileSync, existsSync, unlinkSync, re
11
11
  import { join, dirname } from 'node:path';
12
12
  import { spawn } from 'node:child_process';
13
13
  import { fileURLToPath } from 'node:url';
14
- import { randomUUID } from 'node:crypto';
14
+ import { createHash } from 'node:crypto';
15
15
  import {
16
16
  ensureDataDir,
17
17
  PENDING_DIR,
@@ -35,6 +35,16 @@ try {
35
35
 
36
36
  const __dirname = dirname(fileURLToPath(import.meta.url));
37
37
 
38
+ /**
39
+ * Generate a deterministic event_id from raw stdin content.
40
+ * Both hooks receive byte-identical stdin, so they produce the same ID.
41
+ * Format matches UUID pattern (8-4-4-4-12 hex) for backward compatibility.
42
+ */
43
+ export function deterministicEventId(rawStdin) {
44
+ const hex = createHash('sha256').update(rawStdin).digest('hex');
45
+ return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20,32)}`;
46
+ }
47
+
38
48
  function logDrop(reason, meta = {}) {
39
49
  if (process.env.AI_LENS_DRY_RUN) return;
40
50
  try {
@@ -349,7 +359,7 @@ function normalizeClaudeCode(event) {
349
359
  }
350
360
 
351
361
  return {
352
- event_id: randomUUID(),
362
+ event_id: null,
353
363
  source: 'claude_code',
354
364
  session_id: sessionId,
355
365
  type,
@@ -518,7 +528,7 @@ function normalizeCursor(event) {
518
528
  }
519
529
 
520
530
  return {
521
- event_id: randomUUID(),
531
+ event_id: null,
522
532
  source: 'cursor',
523
533
  session_id: sessionId,
524
534
  type,
@@ -553,8 +563,11 @@ function writeToSpool(unified) {
553
563
  // to prevent regex patterns from matching across JSON structural boundaries
554
564
  if (toWrite.data) toWrite.data = redactObject(toWrite.data);
555
565
  if (toWrite.raw) toWrite.raw = redactObject(toWrite.raw);
556
- // Write via tmp→rename for atomicity: sender never sees a partial file
557
- const filename = `${Date.now()}-${unified.event_id}.json`;
566
+ // Write via tmp→rename for atomicity: sender never sees a partial file.
567
+ // Filename is based solely on event_id (deterministic from stdin hash).
568
+ // If two hooks fire for the same event, both target the same filename —
569
+ // the second renameSync atomically overwrites the first (identical content).
570
+ const filename = `${unified.event_id}.json`;
558
571
  const dstPath = join(PENDING_DIR, filename);
559
572
  const tmpPath = dstPath + '.tmp.' + process.pid;
560
573
  writeFileSync(tmpPath, JSON.stringify(toWrite));
@@ -629,6 +642,10 @@ async function main() {
629
642
  process.exit(0);
630
643
  }
631
644
 
645
+ // Deterministic event_id from raw stdin — both hooks receive identical bytes,
646
+ // so they produce the same ID and ON CONFLICT(event_id) catches the duplicate.
647
+ unified.event_id = deterministicEventId(input);
648
+
632
649
  // Filter by monitored projects (if configured)
633
650
  const monitored = getMonitoredProjects();
634
651
  let projectPath = unified.project_path;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.37",
3
+ "version": "0.8.38",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {