@yawlabs/ctxlint 0.6.0 → 0.7.0

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/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  // src/index.ts
4
4
  if (process.argv.includes("--mcp-server")) {
5
- await import("./server-4SC5VMA7.js");
5
+ await import("./server-ADUSCPPU.js");
6
6
  } else {
7
- const { runCli } = await import("./cli-XQVUFK2D.js");
7
+ const { runCli } = await import("./cli-7DNRBGDO.js");
8
8
  await runCli();
9
9
  }
@@ -223,20 +223,20 @@ async function scanForMcpConfigs(projectRoot) {
223
223
  async function scanGlobalMcpConfigs() {
224
224
  const found = [];
225
225
  const seen = /* @__PURE__ */ new Set();
226
- const home = process.env.HOME || process.env.USERPROFILE || "";
226
+ const home2 = process.env.HOME || process.env.USERPROFILE || "";
227
227
  const globalPaths = [
228
- path2.join(home, ".claude.json"),
229
- path2.join(home, ".claude", "settings.json"),
230
- path2.join(home, ".cursor", "mcp.json"),
231
- path2.join(home, ".codeium", "windsurf", "mcp_config.json"),
232
- path2.join(home, ".aws", "amazonq", "mcp.json")
228
+ path2.join(home2, ".claude.json"),
229
+ path2.join(home2, ".claude", "settings.json"),
230
+ path2.join(home2, ".cursor", "mcp.json"),
231
+ path2.join(home2, ".codeium", "windsurf", "mcp_config.json"),
232
+ path2.join(home2, ".aws", "amazonq", "mcp.json")
233
233
  ];
234
234
  if (process.platform === "darwin") {
235
235
  globalPaths.push(
236
- path2.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json")
236
+ path2.join(home2, "Library", "Application Support", "Claude", "claude_desktop_config.json")
237
237
  );
238
238
  } else if (process.platform === "win32") {
239
- const appData = process.env.APPDATA || path2.join(home, "AppData", "Roaming");
239
+ const appData = process.env.APPDATA || path2.join(home2, "AppData", "Roaming");
240
240
  globalPaths.push(path2.join(appData, "Claude", "claude_desktop_config.json"));
241
241
  }
242
242
  for (const filePath of globalPaths) {
@@ -252,7 +252,7 @@ async function scanGlobalMcpConfigs() {
252
252
  const target = symlink ? readSymlinkTarget(normalized) : void 0;
253
253
  found.push({
254
254
  absolutePath: normalized,
255
- relativePath: "~/" + path2.relative(home, normalized).replace(/\\/g, "/"),
255
+ relativePath: "~/" + path2.relative(home2, normalized).replace(/\\/g, "/"),
256
256
  isSymlink: symlink,
257
257
  symlinkTarget: target,
258
258
  type: "mcp-config"
@@ -2311,9 +2311,494 @@ async function checkMcpRedundancy(configs) {
2311
2311
  return issues;
2312
2312
  }
2313
2313
 
2314
+ // src/core/session-scanner.ts
2315
+ import { readFile as readFile2, readdir, stat } from "fs/promises";
2316
+ import { dirname as dirname2, join as join4, resolve as resolve4 } from "path";
2317
+ import { existsSync } from "fs";
2318
+ import { simpleGit as simpleGit2 } from "simple-git";
2319
+
2320
+ // src/core/session-parser.ts
2321
+ import { readFile } from "fs/promises";
2322
+ var PATH_PATTERN2 = /(?:^|\s|['"`(])([.~/][^\s'"`),;:!?]+)/g;
2323
+ function decodeProjectDir(dirName) {
2324
+ const parts = dirName.split("--");
2325
+ if (parts.length <= 1) return dirName;
2326
+ if (parts[0].length === 1 && /^[A-Z]$/i.test(parts[0])) {
2327
+ return parts[0] + ":/" + parts.slice(1).join("/");
2328
+ }
2329
+ return "/" + parts.join("/");
2330
+ }
2331
+ function extractPaths(content) {
2332
+ const paths = [];
2333
+ for (const match of content.matchAll(PATH_PATTERN2)) {
2334
+ const p = match[1].replace(/[)}\]]+$/, "");
2335
+ if (p.length > 2 && !p.startsWith("http") && !p.startsWith("//")) {
2336
+ paths.push(p);
2337
+ }
2338
+ }
2339
+ return [...new Set(paths)];
2340
+ }
2341
+ function parseFrontmatter2(content) {
2342
+ const lines = content.split("\n");
2343
+ if (lines[0]?.trim() !== "---") {
2344
+ return { body: content };
2345
+ }
2346
+ const endIdx = lines.indexOf("---", 1);
2347
+ if (endIdx === -1) {
2348
+ return { body: content };
2349
+ }
2350
+ const frontmatter = {};
2351
+ for (let i = 1; i < endIdx; i++) {
2352
+ const line = lines[i];
2353
+ const colonIdx = line.indexOf(":");
2354
+ if (colonIdx > 0) {
2355
+ const key = line.slice(0, colonIdx).trim();
2356
+ const value = line.slice(colonIdx + 1).trim();
2357
+ frontmatter[key] = value;
2358
+ }
2359
+ }
2360
+ return {
2361
+ name: frontmatter["name"],
2362
+ description: frontmatter["description"],
2363
+ type: frontmatter["type"],
2364
+ body: lines.slice(endIdx + 1).join("\n")
2365
+ };
2366
+ }
2367
+ async function parseMemoryFile(filePath, projectDir) {
2368
+ const content = await readFile(filePath, "utf-8");
2369
+ const { name, description, type, body } = parseFrontmatter2(content);
2370
+ const referencedPaths = extractPaths(body);
2371
+ return {
2372
+ filePath,
2373
+ projectDir,
2374
+ name,
2375
+ description,
2376
+ type,
2377
+ content: body,
2378
+ referencedPaths
2379
+ };
2380
+ }
2381
+
2382
+ // src/core/session-scanner.ts
2383
+ var home = process.env.HOME || process.env.USERPROFILE || "";
2384
+ var AGENT_DIRS = [
2385
+ { provider: "claude-code", dir: join4(home, ".claude"), historyFile: "history.jsonl" },
2386
+ { provider: "codex-cli", dir: join4(home, ".codex"), historyFile: "history.jsonl" },
2387
+ { provider: "vibe-cli", dir: join4(home, ".vibe") },
2388
+ {
2389
+ provider: "amazon-q",
2390
+ dir: join4(home, ".aws", "amazonq")
2391
+ },
2392
+ {
2393
+ provider: "goose",
2394
+ dir: process.platform === "win32" ? join4(process.env.APPDATA || "", "Block", "goose") : join4(home, ".config", "goose")
2395
+ },
2396
+ { provider: "continue", dir: join4(home, ".continue") },
2397
+ {
2398
+ provider: "windsurf",
2399
+ dir: join4(home, ".windsurf")
2400
+ }
2401
+ ];
2402
+ function detectProviders() {
2403
+ return AGENT_DIRS.filter((a) => existsSync(a.dir)).map((a) => a.provider);
2404
+ }
2405
+ async function parseJsonlFiltered(filePath, filter) {
2406
+ if (!existsSync(filePath)) return [];
2407
+ const content = await readFile2(filePath, "utf-8");
2408
+ const results = [];
2409
+ for (const line of content.split("\n")) {
2410
+ if (!line.trim()) continue;
2411
+ try {
2412
+ const parsed = JSON.parse(line);
2413
+ const result = filter(parsed);
2414
+ if (result) results.push(result);
2415
+ } catch {
2416
+ }
2417
+ }
2418
+ return results;
2419
+ }
2420
+ async function readClaudeHistory() {
2421
+ const historyPath = join4(home, ".claude", "history.jsonl");
2422
+ return parseJsonlFiltered(historyPath, (entry) => {
2423
+ if (!entry.display || !entry.project) return null;
2424
+ return {
2425
+ display: entry.display,
2426
+ timestamp: entry.timestamp || 0,
2427
+ project: entry.project.replace(/\\/g, "/"),
2428
+ sessionId: entry.sessionId || "",
2429
+ provider: "claude-code"
2430
+ };
2431
+ });
2432
+ }
2433
+ async function readCodexHistory() {
2434
+ const historyPath = join4(home, ".codex", "history.jsonl");
2435
+ return parseJsonlFiltered(historyPath, (entry) => {
2436
+ if (!entry.display && !entry.command) return null;
2437
+ return {
2438
+ display: entry.display || entry.command || "",
2439
+ timestamp: entry.timestamp || 0,
2440
+ project: (entry.project || entry.cwd || "").replace(/\\/g, "/"),
2441
+ sessionId: entry.sessionId || "",
2442
+ provider: "codex-cli"
2443
+ };
2444
+ });
2445
+ }
2446
+ async function readClaudeMemories() {
2447
+ const projectsDir = join4(home, ".claude", "projects");
2448
+ if (!existsSync(projectsDir)) return [];
2449
+ const memories = [];
2450
+ const projectDirs = await readdir(projectsDir).catch(() => []);
2451
+ for (const projDir of projectDirs) {
2452
+ const memoryDir = join4(projectsDir, projDir, "memory");
2453
+ if (!existsSync(memoryDir)) continue;
2454
+ const decodedPath = decodeProjectDir(projDir);
2455
+ const files = await readdir(memoryDir).catch(() => []);
2456
+ for (const file of files) {
2457
+ if (!file.endsWith(".md") || file === "MEMORY.md") continue;
2458
+ try {
2459
+ const entry = await parseMemoryFile(join4(memoryDir, file), decodedPath);
2460
+ memories.push(entry);
2461
+ } catch {
2462
+ }
2463
+ }
2464
+ }
2465
+ return memories;
2466
+ }
2467
+ function detectAiderInSiblings(siblings) {
2468
+ for (const sib of siblings) {
2469
+ if (existsSync(join4(sib.path, ".aider.chat.history.md"))) {
2470
+ return "aider";
2471
+ }
2472
+ }
2473
+ return null;
2474
+ }
2475
+ async function detectSiblings(projectRoot) {
2476
+ const parentDir = dirname2(resolve4(projectRoot));
2477
+ const siblings = [];
2478
+ let entries;
2479
+ try {
2480
+ entries = await readdir(parentDir);
2481
+ } catch {
2482
+ return [];
2483
+ }
2484
+ for (const entry of entries) {
2485
+ const fullPath = join4(parentDir, entry);
2486
+ const entryPath = resolve4(fullPath);
2487
+ if (entryPath === resolve4(projectRoot)) continue;
2488
+ try {
2489
+ const s = await stat(fullPath);
2490
+ if (!s.isDirectory()) continue;
2491
+ } catch {
2492
+ continue;
2493
+ }
2494
+ if (entry.startsWith(".") || entry === "node_modules") continue;
2495
+ const isProject = existsSync(join4(fullPath, ".git")) || existsSync(join4(fullPath, "package.json")) || existsSync(join4(fullPath, "Cargo.toml")) || existsSync(join4(fullPath, "go.mod")) || existsSync(join4(fullPath, "pyproject.toml"));
2496
+ if (!isProject) continue;
2497
+ const sibling = {
2498
+ path: entryPath.replace(/\\/g, "/"),
2499
+ name: entry
2500
+ };
2501
+ try {
2502
+ const git = simpleGit2(fullPath);
2503
+ const remotes = await git.getRemotes(true);
2504
+ const origin = remotes.find((r) => r.name === "origin");
2505
+ if (origin?.refs?.fetch) {
2506
+ sibling.gitRemoteUrl = origin.refs.fetch;
2507
+ const orgMatch = origin.refs.fetch.match(/github\.com[:/]([^/]+)\//);
2508
+ if (orgMatch) sibling.gitOrg = orgMatch[1];
2509
+ }
2510
+ } catch {
2511
+ }
2512
+ siblings.push(sibling);
2513
+ }
2514
+ if (siblings.length > 50) {
2515
+ const currentGit = simpleGit2(projectRoot);
2516
+ let currentOrg;
2517
+ try {
2518
+ const remotes = await currentGit.getRemotes(true);
2519
+ const origin = remotes.find((r) => r.name === "origin");
2520
+ const orgMatch = origin?.refs?.fetch?.match(/github\.com[:/]([^/]+)\//);
2521
+ if (orgMatch) currentOrg = orgMatch[1];
2522
+ } catch {
2523
+ }
2524
+ if (currentOrg) {
2525
+ return siblings.filter((s) => s.gitOrg === currentOrg);
2526
+ }
2527
+ }
2528
+ return siblings;
2529
+ }
2530
+ async function scanSessionData(projectRoot) {
2531
+ const providers = detectProviders();
2532
+ const historyPromises = [];
2533
+ if (providers.includes("claude-code")) historyPromises.push(readClaudeHistory());
2534
+ if (providers.includes("codex-cli")) historyPromises.push(readCodexHistory());
2535
+ const [histories, memories, siblings] = await Promise.all([
2536
+ Promise.all(historyPromises).then((arrays) => arrays.flat()),
2537
+ providers.includes("claude-code") ? readClaudeMemories() : Promise.resolve([]),
2538
+ detectSiblings(projectRoot)
2539
+ ]);
2540
+ const aider = detectAiderInSiblings(siblings);
2541
+ if (aider && !providers.includes("aider")) {
2542
+ providers.push("aider");
2543
+ }
2544
+ return {
2545
+ history: histories,
2546
+ memories,
2547
+ siblings,
2548
+ currentProject: resolve4(projectRoot).replace(/\\/g, "/"),
2549
+ providers
2550
+ };
2551
+ }
2552
+
2553
+ // src/core/checks/session/missing-secret.ts
2554
+ import { resolve as resolve5 } from "path";
2555
+ var SECRET_SET_PATTERN = /gh\s+secret\s+set\s+(\S+)\s+(?:--repo\s+(\S+)|.*-b\s+)/;
2556
+ var SECRET_SET_SIMPLE = /gh\s+secret\s+set\s+(\S+)/;
2557
+ async function checkMissingSecret(ctx) {
2558
+ const issues = [];
2559
+ const secrets = [];
2560
+ for (const entry of ctx.history) {
2561
+ const match = entry.display.match(SECRET_SET_PATTERN) || entry.display.match(SECRET_SET_SIMPLE);
2562
+ if (!match) continue;
2563
+ secrets.push({
2564
+ name: match[1],
2565
+ repo: match[2],
2566
+ project: entry.project
2567
+ });
2568
+ }
2569
+ if (secrets.length === 0) return issues;
2570
+ const byName = /* @__PURE__ */ new Map();
2571
+ for (const s of secrets) {
2572
+ if (!byName.has(s.name)) byName.set(s.name, /* @__PURE__ */ new Set());
2573
+ byName.get(s.name).add(s.project);
2574
+ if (s.repo) byName.get(s.name).add(s.repo);
2575
+ }
2576
+ const currentNorm = resolve5(ctx.currentProject).replace(/\\/g, "/");
2577
+ for (const [secretName, projects] of byName) {
2578
+ const currentHas = [...projects].some(
2579
+ (p) => p.includes(currentNorm) || currentNorm.includes(p.replace(/\\/g, "/")) || p.includes(resolve5(ctx.currentProject).split(/[/\\]/).pop() || "")
2580
+ );
2581
+ if (currentHas) continue;
2582
+ const siblingMatches = ctx.siblings.filter(
2583
+ (sib) => [...projects].some(
2584
+ (p) => p.replace(/\\/g, "/").includes(sib.name) || p.includes(sib.path)
2585
+ )
2586
+ );
2587
+ if (siblingMatches.length >= 2) {
2588
+ const sibNames = siblingMatches.map((s) => s.name).join(", ");
2589
+ issues.push({
2590
+ severity: "error",
2591
+ check: "session-missing-secret",
2592
+ ruleId: "session/missing-secret",
2593
+ line: 0,
2594
+ message: `GitHub secret "${secretName}" is set on ${siblingMatches.length} sibling repos (${sibNames}) but not on this project`,
2595
+ suggestion: `Run: gh secret set ${secretName} --repo <owner>/<repo>`,
2596
+ detail: `Found in agent history: ${siblingMatches.length} sibling repos have this secret configured`
2597
+ });
2598
+ }
2599
+ }
2600
+ return issues;
2601
+ }
2602
+
2603
+ // src/core/checks/session/diverged-file.ts
2604
+ import { readFile as readFile3 } from "fs/promises";
2605
+ import { join as join5 } from "path";
2606
+ import { existsSync as existsSync2 } from "fs";
2607
+ var CANONICAL_FILES = [
2608
+ "release.sh",
2609
+ ".github/workflows/ci.yml",
2610
+ ".github/workflows/release.yml",
2611
+ "biome.json",
2612
+ ".prettierrc",
2613
+ ".eslintrc.json",
2614
+ "tsconfig.json",
2615
+ ".gitignore"
2616
+ ];
2617
+ function calculateOverlap(a, b) {
2618
+ const linesA = a.split("\n").map((l) => l.trim()).filter((l) => l.length > 3);
2619
+ const linesB = new Set(
2620
+ b.split("\n").map((l) => l.trim()).filter((l) => l.length > 3)
2621
+ );
2622
+ if (linesA.length === 0 && linesB.size === 0) return 1;
2623
+ if (linesA.length === 0 || linesB.size === 0) return 0;
2624
+ let matches = 0;
2625
+ for (const line of linesA) {
2626
+ if (linesB.has(line)) matches++;
2627
+ }
2628
+ return matches / Math.max(linesA.length, linesB.size);
2629
+ }
2630
+ async function checkDivergedFile(ctx) {
2631
+ const issues = [];
2632
+ for (const fileName of CANONICAL_FILES) {
2633
+ const currentPath = join5(ctx.currentProject, fileName);
2634
+ if (!existsSync2(currentPath)) continue;
2635
+ let currentContent;
2636
+ try {
2637
+ currentContent = await readFile3(currentPath, "utf-8");
2638
+ } catch {
2639
+ continue;
2640
+ }
2641
+ const diverged = [];
2642
+ for (const sib of ctx.siblings) {
2643
+ const sibPath = join5(sib.path, fileName);
2644
+ if (!existsSync2(sibPath)) continue;
2645
+ try {
2646
+ const sibContent = await readFile3(sibPath, "utf-8");
2647
+ const overlap = calculateOverlap(currentContent, sibContent);
2648
+ if (overlap >= 0.2 && overlap < 0.9) {
2649
+ diverged.push({ sibling: sib.name, overlap: Math.round(overlap * 100) });
2650
+ }
2651
+ } catch {
2652
+ continue;
2653
+ }
2654
+ }
2655
+ if (diverged.length > 0) {
2656
+ const details = diverged.map((d) => `${d.sibling} (${d.overlap}% overlap)`).join(", ");
2657
+ issues.push({
2658
+ severity: "warning",
2659
+ check: "session-diverged-file",
2660
+ ruleId: "session/diverged-file",
2661
+ line: 0,
2662
+ message: `${fileName} has diverged from sibling repos: ${details}`,
2663
+ suggestion: `Compare with sibling versions to identify unintentional drift`,
2664
+ detail: `Files with the same name across sibling repos should be kept in sync when they serve the same purpose`
2665
+ });
2666
+ }
2667
+ }
2668
+ return issues;
2669
+ }
2670
+
2671
+ // src/core/checks/session/missing-workflow.ts
2672
+ import { readdir as readdir2 } from "fs/promises";
2673
+ import { join as join6 } from "path";
2674
+ import { existsSync as existsSync3 } from "fs";
2675
+ async function checkMissingWorkflow(ctx) {
2676
+ const issues = [];
2677
+ const currentWorkflowDir = join6(ctx.currentProject, ".github", "workflows");
2678
+ if (!existsSync3(join6(ctx.currentProject, ".github"))) return issues;
2679
+ const currentWorkflows = /* @__PURE__ */ new Set();
2680
+ if (existsSync3(currentWorkflowDir)) {
2681
+ try {
2682
+ const files = await readdir2(currentWorkflowDir);
2683
+ for (const f of files) {
2684
+ if (f.endsWith(".yml") || f.endsWith(".yaml")) currentWorkflows.add(f);
2685
+ }
2686
+ } catch {
2687
+ }
2688
+ }
2689
+ const workflowMap = /* @__PURE__ */ new Map();
2690
+ for (const sib of ctx.siblings) {
2691
+ const sibWorkflowDir = join6(sib.path, ".github", "workflows");
2692
+ if (!existsSync3(sibWorkflowDir)) continue;
2693
+ try {
2694
+ const files = await readdir2(sibWorkflowDir);
2695
+ for (const f of files) {
2696
+ if (!(f.endsWith(".yml") || f.endsWith(".yaml"))) continue;
2697
+ if (!workflowMap.has(f)) workflowMap.set(f, []);
2698
+ workflowMap.get(f).push(sib.name);
2699
+ }
2700
+ } catch {
2701
+ continue;
2702
+ }
2703
+ }
2704
+ for (const [workflow, siblings] of workflowMap) {
2705
+ if (currentWorkflows.has(workflow)) continue;
2706
+ if (siblings.length < 2) continue;
2707
+ const sibNames = siblings.join(", ");
2708
+ issues.push({
2709
+ severity: "warning",
2710
+ check: "session-missing-workflow",
2711
+ ruleId: "session/missing-workflow",
2712
+ line: 0,
2713
+ message: `GitHub Actions workflow "${workflow}" exists in ${siblings.length} sibling repos (${sibNames}) but not in this project`,
2714
+ suggestion: `Consider adding .github/workflows/${workflow} for consistency`,
2715
+ detail: `Sibling repos with this workflow: ${sibNames}`
2716
+ });
2717
+ }
2718
+ return issues;
2719
+ }
2720
+
2721
+ // src/core/checks/session/stale-memory.ts
2722
+ import { existsSync as existsSync4 } from "fs";
2723
+ import { resolve as resolve6, isAbsolute } from "path";
2724
+ async function checkStaleMemory(ctx) {
2725
+ const issues = [];
2726
+ const currentNorm = ctx.currentProject.replace(/\\/g, "/");
2727
+ const projectMemories = ctx.memories.filter(
2728
+ (m) => m.projectDir.replace(/\\/g, "/") === currentNorm
2729
+ );
2730
+ for (const mem of projectMemories) {
2731
+ const brokenPaths = [];
2732
+ for (const ref of mem.referencedPaths) {
2733
+ const fullPath = isAbsolute(ref) ? ref : resolve6(ctx.currentProject, ref);
2734
+ if (!existsSync4(fullPath)) {
2735
+ brokenPaths.push(ref);
2736
+ }
2737
+ }
2738
+ if (brokenPaths.length > 0) {
2739
+ const name = mem.name || mem.filePath.split(/[/\\]/).pop() || "unknown";
2740
+ issues.push({
2741
+ severity: "info",
2742
+ check: "session-stale-memory",
2743
+ ruleId: "session/stale-memory",
2744
+ line: 0,
2745
+ message: `Memory "${name}" references ${brokenPaths.length} path(s) that no longer exist: ${brokenPaths.join(", ")}`,
2746
+ suggestion: `Update or remove the memory file: ${mem.filePath}`,
2747
+ detail: `Memory files with broken path references may cause the AI agent to follow stale instructions`
2748
+ });
2749
+ }
2750
+ }
2751
+ return issues;
2752
+ }
2753
+
2754
+ // src/core/checks/session/duplicate-memory.ts
2755
+ function calculateLineOverlap2(a, b) {
2756
+ const linesA = a.split("\n").map((l) => l.trim()).filter((l) => l.length > 5);
2757
+ const linesB = new Set(
2758
+ b.split("\n").map((l) => l.trim()).filter((l) => l.length > 5)
2759
+ );
2760
+ if (linesA.length === 0 || linesB.size === 0) return 0;
2761
+ let matches = 0;
2762
+ for (const line of linesA) {
2763
+ if (linesB.has(line)) matches++;
2764
+ }
2765
+ return matches / Math.max(linesA.length, linesB.size);
2766
+ }
2767
+ async function checkDuplicateMemory(ctx) {
2768
+ const issues = [];
2769
+ const reported = /* @__PURE__ */ new Set();
2770
+ for (let i = 0; i < ctx.memories.length; i++) {
2771
+ for (let j = i + 1; j < ctx.memories.length; j++) {
2772
+ const a = ctx.memories[i];
2773
+ const b = ctx.memories[j];
2774
+ if (a.projectDir.replace(/\\/g, "/") === b.projectDir.replace(/\\/g, "/")) continue;
2775
+ if (a.content.length < 50 || b.content.length < 50) continue;
2776
+ const overlap = calculateLineOverlap2(a.content, b.content);
2777
+ if (overlap < 0.6) continue;
2778
+ const pairKey = [a.filePath, b.filePath].sort().join("::");
2779
+ if (reported.has(pairKey)) continue;
2780
+ reported.add(pairKey);
2781
+ const nameA = a.name || a.filePath.split(/[/\\]/).pop() || "unknown";
2782
+ const nameB = b.name || b.filePath.split(/[/\\]/).pop() || "unknown";
2783
+ const projA = a.projectDir.split(/[/\\]/).pop() || a.projectDir;
2784
+ const projB = b.projectDir.split(/[/\\]/).pop() || b.projectDir;
2785
+ issues.push({
2786
+ severity: "info",
2787
+ check: "session-duplicate-memory",
2788
+ ruleId: "session/duplicate-memory",
2789
+ line: 0,
2790
+ message: `Memory "${nameA}" (${projA}) and "${nameB}" (${projB}) have ${Math.round(overlap * 100)}% overlap`,
2791
+ suggestion: `Consider consolidating into a shared memory or removing the duplicate`,
2792
+ detail: `Near-duplicate memories across projects waste context and may drift out of sync`
2793
+ });
2794
+ }
2795
+ }
2796
+ return issues;
2797
+ }
2798
+
2314
2799
  // src/version.ts
2315
2800
  function loadVersion() {
2316
- if (true) return "0.6.0";
2801
+ if (true) return "0.7.0";
2317
2802
  const fs6 = __require("fs");
2318
2803
  const path9 = __require("path");
2319
2804
  const pkgPath = path9.resolve(__dirname, "../package.json");
@@ -2342,13 +2827,24 @@ var ALL_MCP_CHECKS = [
2342
2827
  "mcp-consistency",
2343
2828
  "mcp-redundancy"
2344
2829
  ];
2830
+ var ALL_SESSION_CHECKS = [
2831
+ "session-missing-secret",
2832
+ "session-diverged-file",
2833
+ "session-missing-workflow",
2834
+ "session-stale-memory",
2835
+ "session-duplicate-memory"
2836
+ ];
2345
2837
  function hasMcpChecks(checks) {
2346
2838
  return checks.some((c) => c.startsWith("mcp-"));
2347
2839
  }
2840
+ function hasSessionChecks(checks) {
2841
+ return checks.some((c) => c.startsWith("session-"));
2842
+ }
2348
2843
  async function runAudit(projectRoot, activeChecks, options = {}) {
2349
2844
  const fileResults = [];
2350
- const shouldRunContextChecks = !options.mcpOnly;
2845
+ const shouldRunContextChecks = !options.mcpOnly && !options.sessionOnly;
2351
2846
  const shouldRunMcpChecks = options.mcp || options.mcpGlobal || options.mcpOnly || hasMcpChecks(activeChecks);
2847
+ const shouldRunSessionChecks = options.session || options.sessionOnly || hasSessionChecks(activeChecks);
2352
2848
  if (shouldRunContextChecks) {
2353
2849
  const discovered = await scanForContextFiles(projectRoot, {
2354
2850
  depth: options.depth,
@@ -2450,6 +2946,37 @@ async function runAudit(projectRoot, activeChecks, options = {}) {
2450
2946
  }
2451
2947
  }
2452
2948
  }
2949
+ if (shouldRunSessionChecks) {
2950
+ const activeSessionChecks = activeChecks.filter(
2951
+ (c) => c.startsWith("session-")
2952
+ );
2953
+ const sessionChecksToRun = activeSessionChecks.length > 0 ? activeSessionChecks : options.session || options.sessionOnly ? ALL_SESSION_CHECKS : [];
2954
+ if (sessionChecksToRun.length > 0) {
2955
+ const sessionCtx = await scanSessionData(projectRoot);
2956
+ const sessionPromises = [];
2957
+ if (sessionChecksToRun.includes("session-missing-secret"))
2958
+ sessionPromises.push(checkMissingSecret(sessionCtx));
2959
+ if (sessionChecksToRun.includes("session-diverged-file"))
2960
+ sessionPromises.push(checkDivergedFile(sessionCtx));
2961
+ if (sessionChecksToRun.includes("session-missing-workflow"))
2962
+ sessionPromises.push(checkMissingWorkflow(sessionCtx));
2963
+ if (sessionChecksToRun.includes("session-stale-memory"))
2964
+ sessionPromises.push(checkStaleMemory(sessionCtx));
2965
+ if (sessionChecksToRun.includes("session-duplicate-memory"))
2966
+ sessionPromises.push(checkDuplicateMemory(sessionCtx));
2967
+ const sessionResults = await Promise.all(sessionPromises);
2968
+ const sessionIssues = sessionResults.flat();
2969
+ if (sessionIssues.length > 0) {
2970
+ fileResults.push({
2971
+ path: "~/.claude/ (session audit)",
2972
+ isSymlink: false,
2973
+ tokens: 0,
2974
+ lines: 0,
2975
+ issues: sessionIssues
2976
+ });
2977
+ }
2978
+ }
2979
+ }
2453
2980
  let estimatedWaste = 0;
2454
2981
  for (const fr of fileResults) {
2455
2982
  for (const issue of fr.issues) {
@@ -2551,7 +3078,12 @@ var checkEnum = z.enum([
2551
3078
  "mcp-env",
2552
3079
  "mcp-urls",
2553
3080
  "mcp-consistency",
2554
- "mcp-redundancy"
3081
+ "mcp-redundancy",
3082
+ "session-missing-secret",
3083
+ "session-diverged-file",
3084
+ "session-missing-workflow",
3085
+ "session-stale-memory",
3086
+ "session-duplicate-memory"
2555
3087
  ]);
2556
3088
  var server = new McpServer({
2557
3089
  name: "ctxlint",
@@ -2760,5 +3292,39 @@ server.tool(
2760
3292
  }
2761
3293
  }
2762
3294
  );
3295
+ server.tool(
3296
+ "ctxlint_session_audit",
3297
+ "Audit AI agent session data for cross-project consistency. Checks for missing GitHub secrets, diverged config files, missing workflows, stale memory entries, and duplicate memories across sibling repositories.",
3298
+ {
3299
+ projectPath: z.string().optional().describe("Path to the project root. Defaults to current working directory."),
3300
+ checks: z.array(checkEnum).optional().describe("Specific session checks to run (default: all session-* checks).")
3301
+ },
3302
+ {
3303
+ readOnlyHint: true,
3304
+ destructiveHint: false,
3305
+ idempotentHint: true,
3306
+ openWorldHint: true
3307
+ },
3308
+ async ({ projectPath, checks }) => {
3309
+ const root = path8.resolve(projectPath || process.cwd());
3310
+ const activeChecks = checks || ALL_SESSION_CHECKS;
3311
+ try {
3312
+ const result = await runAudit(root, activeChecks, {
3313
+ session: true,
3314
+ sessionOnly: true
3315
+ });
3316
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3317
+ } catch (err) {
3318
+ const msg = err instanceof Error ? err.message : String(err);
3319
+ return {
3320
+ content: [{ type: "text", text: JSON.stringify({ error: msg }) }],
3321
+ isError: true
3322
+ };
3323
+ } finally {
3324
+ freeEncoder();
3325
+ resetGit();
3326
+ }
3327
+ }
3328
+ );
2763
3329
  var transport = new StdioServerTransport();
2764
3330
  await server.connect(transport);