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 +1 -1
- package/cli/hooks.js +63 -1
- package/cli/init.js +20 -1
- package/client/capture.js +22 -5
- package/package.json +1 -1
package/.commithash
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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))
|
|
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 {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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;
|