ai-lens 0.8.61 → 0.8.62

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
- a877b83
1
+ ee4f839
package/client/capture.js CHANGED
@@ -494,14 +494,68 @@ function extractFilePath(toolInput) {
494
494
  }
495
495
 
496
496
  /**
497
- * Walk up from a path to find the nearest .git directory.
498
- * Returns the git root (parent of .git) or null.
497
+ * Given a directory that contains a `.git` entry, return the real repo root.
498
+ * If `.git` is a directory, the input is already the repo root. If `.git` is
499
+ * a FILE (git worktree — contains "gitdir: <path-to-main>/.git/worktrees/<n>")
500
+ * we follow the pointer up to the main `.git` directory and return its parent
501
+ * so worktree sessions attribute to the main repo rather than a per-branch
502
+ * pseudo-project (e.g. `agent-a8d9bb19`, `ANL-689`).
503
+ *
504
+ * Submodules also use a `.git` file, but its gitdir points under
505
+ * `<super>/.git/modules/<name>`. We intentionally skip those so edits inside
506
+ * a submodule keep attributing to the submodule, not the super-project —
507
+ * that's outside the scope of ANL-729.
508
+ *
509
+ * Never returns a path "outside" the filesystem; on any parse failure falls
510
+ * back to the original dir.
511
+ */
512
+ function resolveWorktreeToMainRepo(dir) {
513
+ try {
514
+ const gitEntry = join(dir, '.git');
515
+ const st = statSync(gitEntry);
516
+ if (st.isDirectory()) return dir;
517
+ if (!st.isFile()) return dir;
518
+
519
+ const contents = readFileSync(gitEntry, 'utf-8');
520
+ const match = contents.match(/^\s*gitdir:\s*(.+?)\s*$/m);
521
+ if (!match) return dir;
522
+
523
+ let gitdir = match[1];
524
+ // Resolve relative pointers against the worktree dir. POSIX-only check
525
+ // (`startsWith('/')`) — AI Lens runs on macOS/Linux dev machines, Windows
526
+ // isn't a supported client target.
527
+ if (!gitdir.startsWith('/')) gitdir = join(dir, gitdir);
528
+
529
+ // Only real worktrees: `<main>/.git/worktrees/<name>`. Submodules
530
+ // (`<super>/.git/modules/<name>`) are left alone on purpose.
531
+ if (!gitdir.includes('/.git/worktrees/')) return dir;
532
+
533
+ // Walk up until we hit the directory literally named `.git`; its parent
534
+ // is the main repo. Depth is bounded: gitdir is `<main>/.git/worktrees/<n>`
535
+ // so the `.git` segment is at most 2 levels up — 4 gives a safety margin.
536
+ let cur = gitdir;
537
+ for (let i = 0; i < 4; i++) {
538
+ const parent = dirname(cur);
539
+ if (parent === cur) break;
540
+ if (cur.endsWith('/.git')) return parent;
541
+ cur = parent;
542
+ }
543
+ return dir;
544
+ } catch {
545
+ return dir;
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Walk up from a path to find the nearest .git entry.
551
+ * Returns the repo root (parent of a `.git` directory, or the main repo root
552
+ * when `.git` is a worktree-pointer file) or null.
499
553
  */
500
554
  function findGitRoot(filePath) {
501
555
  let dir = dirname(filePath);
502
556
  while (dir && dir !== '/' && dir.length > 1) {
503
557
  try {
504
- if (existsSync(join(dir, '.git'))) return dir;
558
+ if (existsSync(join(dir, '.git'))) return resolveWorktreeToMainRepo(dir);
505
559
  } catch {}
506
560
  const parent = dirname(dir);
507
561
  if (parent === dir) break;
@@ -510,6 +564,22 @@ function findGitRoot(filePath) {
510
564
  return null;
511
565
  }
512
566
 
567
+ /**
568
+ * If `dir` is a git worktree checkout, return the main repo root; otherwise
569
+ * return `dir` unchanged. Used at session intake where we have a launcher cwd
570
+ * (SessionStart / workspace_roots) and want to avoid attributing the session
571
+ * to a worktree branch name.
572
+ */
573
+ function canonicalizeProjectPath(dir) {
574
+ if (!dir || typeof dir !== 'string') return dir;
575
+ try {
576
+ if (existsSync(join(dir, '.git'))) {
577
+ return resolveWorktreeToMainRepo(dir);
578
+ }
579
+ } catch {}
580
+ return dir;
581
+ }
582
+
513
583
  /**
514
584
  * Refine project_path using file paths from tool events.
515
585
  * Picks the deepest (most specific) git root — correct for nested repos
@@ -617,8 +687,9 @@ function normalizeClaudeCode(event) {
617
687
  let type = CLAUDE_CODE_TYPE_MAP[hookType] || hookType;
618
688
  const timestamp = new Date().toISOString();
619
689
 
620
- // Extract project path from cwd (SessionStart) or cache
621
- let projectPath = event.cwd || null;
690
+ // Extract project path from cwd (SessionStart) or cache. Canonicalize
691
+ // worktree checkouts to the main repo root (ANL-599 follow-up).
692
+ let projectPath = event.cwd ? canonicalizeProjectPath(event.cwd) : null;
622
693
  if (projectPath && sessionId) {
623
694
  cacheSessionPath(sessionId, projectPath);
624
695
  } else if (sessionId) {
@@ -826,6 +897,8 @@ function normalizeCursor(event) {
826
897
  const type = CURSOR_TYPE_MAP[hookName] || hookName;
827
898
  const timestamp = new Date().toISOString();
828
899
  let projectPath = pickWorkspaceRoot(event.workspace_roots);
900
+ // Canonicalize worktree checkouts to main repo root (ANL-599 follow-up).
901
+ if (projectPath) projectPath = canonicalizeProjectPath(projectPath);
829
902
  // Guard: null sessionId would cache/lookup under key "null", contaminating unrelated sessions
830
903
  if (projectPath && sessionId) {
831
904
  cacheSessionPath(sessionId, projectPath);
package/client/codex.js CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, realpathSync } from 'node:fs';
1
+ import { existsSync, realpathSync, statSync, readFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { toNumberOrNull } from './token-usage.js';
@@ -139,13 +139,59 @@ function extractFilePath(input) {
139
139
  return null;
140
140
  }
141
141
 
142
+ // Mirrors `resolveWorktreeToMainRepo` in capture.js (ANL-729). If this
143
+ // changes in one place, change it in the other — we keep the copy inline
144
+ // instead of a shared module to avoid cross-file churn in this pipeline.
145
+ function resolveWorktreeToMainRepo(dir) {
146
+ try {
147
+ const gitEntry = join(dir, '.git');
148
+ const st = statSync(gitEntry);
149
+ if (st.isDirectory()) return dir;
150
+ if (!st.isFile()) return dir;
151
+
152
+ const contents = readFileSync(gitEntry, 'utf-8');
153
+ const match = contents.match(/^\s*gitdir:\s*(.+?)\s*$/m);
154
+ if (!match) return dir;
155
+
156
+ let gitdir = match[1];
157
+ // POSIX-only (`startsWith('/')`) — AI Lens clients are macOS/Linux.
158
+ if (!gitdir.startsWith('/')) gitdir = join(dir, gitdir);
159
+
160
+ // Only real worktrees (`<main>/.git/worktrees/<name>`). Submodules
161
+ // (`<super>/.git/modules/<name>`) are left alone.
162
+ if (!gitdir.includes('/.git/worktrees/')) return dir;
163
+
164
+ // `.git` segment sits 2 levels above the gitdir; 4 is a safety margin.
165
+ let cur = gitdir;
166
+ for (let i = 0; i < 4; i++) {
167
+ const parent = dirname(cur);
168
+ if (parent === cur) break;
169
+ if (cur.endsWith('/.git')) return parent;
170
+ cur = parent;
171
+ }
172
+ return dir;
173
+ } catch {
174
+ return dir;
175
+ }
176
+ }
177
+
178
+ function canonicalizeProjectPath(dir) {
179
+ if (!dir || typeof dir !== 'string') return dir;
180
+ try {
181
+ if (existsSync(join(dir, '.git'))) {
182
+ return resolveWorktreeToMainRepo(dir);
183
+ }
184
+ } catch {}
185
+ return dir;
186
+ }
187
+
142
188
  function findGitRoot(filePath) {
143
189
  if (!filePath || typeof filePath !== 'string' || !filePath.startsWith('/')) return null;
144
190
 
145
191
  let dir = filePath;
146
192
  while (dir && dir !== '/' && dir.length > 1) {
147
193
  try {
148
- if (existsSync(join(dir, '.git'))) return dir;
194
+ if (existsSync(join(dir, '.git'))) return resolveWorktreeToMainRepo(dir);
149
195
  } catch {}
150
196
 
151
197
  const parent = dirname(dir);
@@ -327,7 +373,7 @@ export function normalizeCodexSessionEntries(record, state, streamKey = 'default
327
373
 
328
374
  stream.sessionId = logicalSessionId;
329
375
  stream.rawSessionId = sessionId;
330
- stream.projectPath = cwd;
376
+ stream.projectPath = canonicalizeProjectPath(cwd);
331
377
  stream.hasActivity = false;
332
378
  stream.model = null;
333
379
  events.push(buildUnifiedEvent(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.61",
3
+ "version": "0.8.62",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {
@@ -14,7 +14,9 @@
14
14
  "README.md"
15
15
  ],
16
16
  "scripts": {
17
- "prepare": "git rev-parse --short HEAD > .commithash 2>/dev/null || true",
17
+ "prepare": "git rev-parse --short HEAD > .commithash 2>/dev/null || true; git config core.hooksPath .githooks 2>/dev/null || true",
18
+ "version": "node scripts/check-changelog.js && git add CHANGELOG.md",
19
+ "release": "./scripts/release-cli.sh",
18
20
  "init": "node bin/ai-lens.js init",
19
21
  "remove": "node bin/ai-lens.js remove",
20
22
  "status": "node bin/ai-lens.js status",