ai-lens 0.8.72 → 0.8.74

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
- 78b5c4c
1
+ 87a4956
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  History of changes to the `ai-lens` CLI package on npm. New entries go on top. Format: `## X.Y.Z — YYYY-MM-DD`, followed by user-facing bullets.
4
4
 
5
+ ## 0.8.74 — 2026-06-04
6
+ - fix: committed Claude Code project hooks now resolve `capture.js` via `$CLAUDE_PROJECT_DIR`, so they fire from any working directory — no more `MODULE_NOT_FOUND` after the agent `cd`s into a subdirectory.
7
+ - fix: `ai-lens status` and `ai-lens init --project-hooks` recognize the `$CLAUDE_PROJECT_DIR` hook form as current instead of flagging it outdated or overwriting it.
8
+
9
+ ## 0.8.73 — 2026-05-29
10
+ - fix: spooling an event now retries the atomic `rename` in `~/.ai-lens/pending/` a few times with a short backoff before giving up. On Windows, antivirus/Defender or the search indexer transiently locks the temp file and the rename failed with EPERM, silently dropping that event (observed in prod via the 0.8.70 error-code instrumentation). POSIX is unaffected (its rename is atomic and never hits this).
11
+
5
12
  ## 0.8.72 — 2026-05-29
6
13
  - fix: `status` no longer reports hooks as "outdated" when they already capture reliably. A hook is now considered current if it's GUI-safe — either the per-machine launcher (`run.sh`/`run.cmd`, including the transitional `sh -c` wrapper) OR a `capture.js` command with an absolute node path baked in (e.g. `/opt/homebrew/bin/node`). Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) — which break for GUI-launched Cursor/Claude on macOS — stay flagged outdated so `init` rewrites them.
7
14
  - fix: `ai-lens status --report` now prints the full status to the screen just like plain `ai-lens status`, in addition to sending the report to the server. Previously it ran silently. The only difference from plain status is that it POSTs the report instead of writing the local `~/ai-lens-status.txt` file.
package/README.md CHANGED
@@ -308,6 +308,8 @@ npm run dev:dashboard # Dashboard dev server
308
308
 
309
309
  Tests require PostgreSQL — set `DATABASE_URL` or use `docker compose up postgres -d` (test DB `ailens_test` is created automatically).
310
310
 
311
+ Architecture decisions are recorded under [`docs/adr/`](docs/adr/) — e.g. [ADR 0001 — Canonical metric predicates](docs/adr/0001-canonical-metric-predicates.md).
312
+
311
313
  ## Deployment
312
314
 
313
315
  GitLab CI (`.gitlab-ci.yml`) on push to `main`:
package/cli/hooks.js CHANGED
@@ -322,6 +322,8 @@ function resolveDefaultCtx() {
322
322
  * @param {boolean} [opts.useTilde] — write ~/.ai-lens/client/... (portable path for project-hooks)
323
323
  * @param {boolean} [opts.rawPath] — don't quote the target (Claude Code config wants raw)
324
324
  * @param {string} [opts.customPath] — absolute repo-mode path to capture.js (--use-repo-path)
325
+ * @param {string} [opts.projectDirRelPath] — capture.js path relative to the workspace root,
326
+ * emitted as a Claude-Code-only `node "$CLAUDE_PROJECT_DIR/<rel>"` command (committed project hooks)
325
327
  * @param {object} [opts.ctx] — { nodeResolution, platform, clientDir }; lazy-resolved if omitted
326
328
  */
327
329
  export function captureCommand(opts = {}) {
@@ -329,7 +331,7 @@ export function captureCommand(opts = {}) {
329
331
  if (typeof opts === 'boolean') {
330
332
  opts = { useTilde: opts, rawPath: arguments[1], customPath: arguments[2] ?? null };
331
333
  }
332
- const { useTilde = false, rawPath = false, customPath = null, shell = null } = opts;
334
+ const { useTilde = false, rawPath = false, customPath = null, shell = null, projectDirRelPath = null } = opts;
333
335
  const ctx = opts.ctx ?? resolveDefaultCtx();
334
336
  // Tolerate partial ctx (e.g. only nodeResolution supplied) by filling in module
335
337
  // defaults — keeps callers from having to repeat platform/clientDir everywhere.
@@ -341,6 +343,20 @@ export function captureCommand(opts = {}) {
341
343
  // Windows — the two need different escaping for paths with spaces.
342
344
  const isPS = shell === 'powershell';
343
345
 
346
+ // Claude Code committed project hooks: resolve capture.js via Claude Code's
347
+ // $CLAUDE_PROJECT_DIR (the workspace root, injected into the hook environment).
348
+ // Claude Code runs the command through a shell, so the var expands regardless of
349
+ // the agent's cwd — a path relative to cwd breaks the moment the agent cd's into a
350
+ // subdir/subrepo (MODULE_NOT_FOUND). Because this string is committed to the repo
351
+ // it must stay machine-agnostic: bare `node` + the env var, never an absolute node
352
+ // binary or /home/<user> path. Windows Claude Code shells via cmd.exe (%VAR%);
353
+ // POSIX shells use $VAR.
354
+ if (projectDirRelPath != null) {
355
+ const rel = projectDirRelPath.replace(/\\/g, '/').replace(/^\.?\/+/, '');
356
+ const dir = isWin ? '%CLAUDE_PROJECT_DIR%' : '$CLAUDE_PROJECT_DIR';
357
+ return `node "${dir}/${rel}"`;
358
+ }
359
+
344
360
  // --use-repo-path: legacy <node> <capture.js> form.
345
361
  if (customPath != null) {
346
362
  if (!nodeResolution || !nodeResolution.path) {
@@ -618,6 +634,26 @@ export function getClaudeCodeHookDefsWithPath(capturePath, ctx = null) {
618
634
  return defs;
619
635
  }
620
636
 
637
+ /**
638
+ * Claude Code hook defs for committed PROJECT hooks (.claude/settings.json checked
639
+ * into the repo). Resolves capture.js via Claude Code's $CLAUDE_PROJECT_DIR so the
640
+ * hook fires from any cwd and the path stays machine-agnostic (no absolute node/path).
641
+ * This is the cwd-robust replacement for the `--use-repo-path --project-hooks` form,
642
+ * whose project-root-relative path broke when an agent cd'd into a subdir.
643
+ * @param {string} relPath - capture.js path relative to the workspace root
644
+ * (e.g. internal/analytics/ai-lens/client/capture.js). Must contain an `ai-lens/`
645
+ * segment so isAiLensCommand recognises it.
646
+ * @param {object} [ctx]
647
+ */
648
+ export function getClaudeCodeHookDefsWithProjectDir(relPath, ctx = null) {
649
+ const getCmd = memoizeCmd(() => captureCommand({ projectDirRelPath: relPath, ctx }));
650
+ const defs = {};
651
+ for (const [name, spec] of Object.entries(CLAUDE_HOOK_SPEC)) {
652
+ defs[name] = () => ({ matcher: spec.matcher, hooks: [{ type: 'command', command: getCmd() }] });
653
+ }
654
+ return defs;
655
+ }
656
+
621
657
  /**
622
658
  * Cursor hook defs that point to a custom path (e.g. REPO_CAPTURE_PATH for --use-repo-path).
623
659
  * @param {string} capturePath - Absolute path to client/capture.js.
@@ -879,19 +915,39 @@ export function isGuiSafeHookCommand(cmd) {
879
915
  return false;
880
916
  }
881
917
 
918
+ // A committed Claude Code PROJECT hook that resolves capture.js through Claude Code's
919
+ // $CLAUDE_PROJECT_DIR ($VAR on POSIX, %VAR% on Windows). This form intentionally uses
920
+ // bare `node` (it's checked into the repo and must work on every teammate's machine,
921
+ // so it can't bake an absolute node path), and Claude Code guarantees the var points
922
+ // at the workspace root regardless of cwd. The var is Claude-Code-specific and never
923
+ // emitted for Cursor/Codex, so matching it here can't mis-accept another tool's hook.
924
+ export function isClaudeProjectDirCommand(cmd) {
925
+ if (!isAiLensCommand(cmd).isAiLens) return false;
926
+ const n = (cmd || '').replace(/\\/g, '/');
927
+ return /\$CLAUDE_PROJECT_DIR|%CLAUDE_PROJECT_DIR%/.test(n)
928
+ && /ai-lens\/client\/capture\.js/.test(n);
929
+ }
930
+
931
+ // A hook command init/status should leave alone: either GUI-safe (launcher /
932
+ // absolute-node capture.js) OR the committed Claude Code $CLAUDE_PROJECT_DIR form.
933
+ function isAcceptableHookCommand(cmd) {
934
+ return isGuiSafeHookCommand(cmd) || isClaudeProjectDirCommand(cmd);
935
+ }
936
+
882
937
  function isCurrentAiLensHook(entry, expected) {
883
- // "Current" = a GUI-safe install (launcher OR absolute-node capture.js). We do
884
- // NOT require an exact match against the expected command form both valid
885
- // install methods capture reliably, so neither should be reported outdated.
886
- // Only PATH-dependent forms (bare `node`, `/usr/bin/env node`) are outdated.
938
+ // "Current" = a GUI-safe install (launcher OR absolute-node capture.js) OR a
939
+ // committed Claude Code $CLAUDE_PROJECT_DIR project hook. We do NOT require an exact
940
+ // match against the expected command form every valid install method captures
941
+ // reliably, so none should be reported outdated. Only PATH-dependent forms
942
+ // (bare `node`, `/usr/bin/env node`) WITHOUT $CLAUDE_PROJECT_DIR are outdated.
887
943
  // Flat format (Cursor): single command per entry.
888
944
  if (entry?.command != null) {
889
- return isGuiSafeHookCommand(entry.command);
945
+ return isAcceptableHookCommand(entry.command);
890
946
  }
891
947
  // Nested format (Claude Code / Codex): { matcher, hooks: [{ command }] }
892
948
  if (Array.isArray(entry?.hooks)) {
893
949
  if (expected?.matcher !== undefined && entry.matcher !== expected.matcher) return false;
894
- return entry.hooks.some(h => isGuiSafeHookCommand(h?.command || ''));
950
+ return entry.hooks.some(h => isAcceptableHookCommand(h?.command || ''));
895
951
  }
896
952
  return false;
897
953
  }
package/cli/init.js CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  CAPTURE_PATH, REPO_CAPTURE_PATH, detectInstalledTools, getCursorToolConfig, getClaudeCodeToolConfig, getCodexToolConfig,
17
17
  analyzeToolHooks, buildMergedConfig, writeHooksConfig, describePlan, enableCodexHookTrust,
18
18
  installClientFiles, readLensConfig, saveLensConfig, getVersionInfo,
19
- getClaudeCodeHookDefsWithPath, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
19
+ getClaudeCodeHookDefsWithPath, getClaudeCodeHookDefsWithProjectDir, getCursorHookDefsWithPath, getCodexHookDefsWithPath,
20
20
  cleanupLegacyHooks, cleanupOppositeScope, cleanupEmptyMcpJson, addCursorMcp, removeCursorMcp,
21
21
  checkHooksDisabled, enableHooks,
22
22
  findStableNodePath, isVersionPinnedNodePath, writeLauncher,
@@ -177,7 +177,14 @@ function getTrackedRoots(projects, fallbackRoot) {
177
177
  function makeNestedClaudeTool(projectDir, capturePathInHooks, ctx = null) {
178
178
  const tool = getClaudeCodeToolConfig(projectDir, `Claude Code (${projectDir})`, ctx);
179
179
  if (capturePathInHooks) {
180
- tool.hookDefs = getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
180
+ // A relative path carrying the ai-lens/ segment is resolved via Claude Code's
181
+ // $CLAUDE_PROJECT_DIR (the nested project's own root) so the hook fires from any
182
+ // cwd. Absolute/tilde paths keep the legacy <node> <capture.js> form.
183
+ const rel = capturePathInHooks.replace(/\\/g, '/');
184
+ const isRelative = !rel.startsWith('/') && !rel.startsWith('~') && !/^[a-zA-Z]:\//.test(rel);
185
+ tool.hookDefs = (isRelative && /(?:^|\/)ai-lens\//.test(rel))
186
+ ? getClaudeCodeHookDefsWithProjectDir(rel, ctx)
187
+ : getClaudeCodeHookDefsWithPath(capturePathInHooks, ctx);
181
188
  }
182
189
  return tool;
183
190
  }
@@ -458,6 +465,10 @@ export default async function init() {
458
465
  const repoPathAbs = resolve(REPO_CAPTURE_PATH);
459
466
  const home = homedir();
460
467
  let pathInHooks;
468
+ // When set, Claude Code project hooks resolve capture.js via $CLAUDE_PROJECT_DIR
469
+ // (cwd-robust + machine-agnostic) instead of a cwd-relative path that breaks the
470
+ // moment an agent cd's into a subdir. Holds the workspace-root-relative path.
471
+ let claudeProjectDirRel = null;
461
472
  if (flags.projectHooks) {
462
473
  const projectRoot = resolve(process.cwd());
463
474
  pathInHooks = relative(projectRoot, repoPathAbs).replace(/\\/g, '/');
@@ -472,7 +483,10 @@ export default async function init() {
472
483
  : repoPathAbs.replace(/\\/g, '/');
473
484
  info(' Project root is inside ai-lens itself — using portable path with ai-lens/ segment for hook recognition.');
474
485
  } else {
475
- info(' Project hooks will use relative path to capture.js (from project root).');
486
+ // Relative path carries the ai-lens/ segment Claude Code can resolve it via
487
+ // $CLAUDE_PROJECT_DIR. The plain relative path stays the form for Cursor/Codex.
488
+ claudeProjectDirRel = pathInHooks;
489
+ info(' Claude Code project hooks will resolve capture.js via $CLAUDE_PROJECT_DIR (cwd-independent).');
476
490
  }
477
491
  } else {
478
492
  pathInHooks = repoPathAbs.startsWith(home)
@@ -488,7 +502,11 @@ export default async function init() {
488
502
  ? ('~' + repoPathAbs.slice(home.length)).replace(/\\/g, '/')
489
503
  : repoPathAbs.replace(/\\/g, '/');
490
504
  for (const tool of tools) {
491
- if (tool.name.startsWith('Claude Code')) tool.hookDefs = getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
505
+ if (tool.name.startsWith('Claude Code')) {
506
+ tool.hookDefs = claudeProjectDirRel
507
+ ? getClaudeCodeHookDefsWithProjectDir(claudeProjectDirRel, ctx)
508
+ : getClaudeCodeHookDefsWithPath(pathInHooks, ctx);
509
+ }
492
510
  else if (tool.name.startsWith('Cursor')) tool.hookDefs = getCursorHookDefsWithPath(pathInHooks, ctx);
493
511
  else if (tool.name.startsWith('Codex')) tool.hookDefs = getCodexHookDefsWithPath(codexPathInHooks, ctx);
494
512
  }
package/client/capture.js CHANGED
@@ -1182,6 +1182,32 @@ export function normalizeEvent(event) {
1182
1182
  // Queue + Sender Spawn
1183
1183
  // =============================================================================
1184
1184
 
1185
+ // Windows (Defender / other AV, search indexer, file-locks) transiently fails an
1186
+ // atomic rename with EPERM/EACCES/EBUSY while another process briefly holds the
1187
+ // .tmp or destination handle. The rename is our spool-write step, so a transient
1188
+ // failure would drop the event (observed in prod: queue-write-failed EPERM on
1189
+ // rename in ~/.ai-lens/pending). Retry a few times with a short synchronous
1190
+ // backoff before giving up. POSIX rename is atomic and never hits these, so this
1191
+ // is a no-op there. Non-transient errors (e.g. ENOENT) are re-thrown immediately.
1192
+ const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
1193
+ export function renameSyncWithRetry(from, to, { attempts = 5, baseDelayMs = 20, renameFn = renameSync } = {}) {
1194
+ for (let i = 1; ; i++) {
1195
+ try {
1196
+ renameFn(from, to);
1197
+ return;
1198
+ } catch (err) {
1199
+ if (i >= attempts || !RENAME_RETRY_CODES.has(err?.code)) throw err;
1200
+ // Synchronous backoff — capture.js runs in a one-shot hook context, no event loop to yield to.
1201
+ try {
1202
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, baseDelayMs * i);
1203
+ } catch {
1204
+ const end = Date.now() + baseDelayMs * i;
1205
+ while (Date.now() < end) { /* spin fallback if Atomics unavailable */ }
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+
1185
1211
  export function writeToSpool(unified) {
1186
1212
  // Shallow copy before redaction — do not mutate the caller's object
1187
1213
  const toWrite = { ...unified };
@@ -1198,7 +1224,7 @@ export function writeToSpool(unified) {
1198
1224
  const tmpPath = dstPath + '.tmp.' + process.pid;
1199
1225
  writeFileSync(tmpPath, JSON.stringify(toWrite));
1200
1226
  try {
1201
- renameSync(tmpPath, dstPath);
1227
+ renameSyncWithRetry(tmpPath, dstPath);
1202
1228
  } catch (err) {
1203
1229
  // Clean up orphaned tmp file (e.g. antivirus/file-lock on Windows)
1204
1230
  try { unlinkSync(tmpPath); } catch {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-lens",
3
- "version": "0.8.72",
3
+ "version": "0.8.74",
4
4
  "type": "module",
5
5
  "description": "Centralized session analytics for AI coding tools",
6
6
  "bin": {