MAESTRO: Merge PRs #920 and #699 - Add project exclusion and folder CLAUDE.md exclusion settings

Cherry-picked both PRs to main (both had merge conflicts with current main).

PR #920 (@Spunky84): CLAUDE_MEM_EXCLUDED_PROJECTS setting with glob patterns
to exclude entire projects from memory tracking (privacy/confidentiality).
Early-exit in session-init and observation handlers. 11 unit tests.

PR #699 (@leepokai): CLAUDE_MEM_FOLDER_MD_EXCLUDE setting with JSON array
of paths to exclude from CLAUDE.md file generation (fixes SwiftUI/Xcode
build conflicts and drizzle kit migration failures). Closes #620.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-02-06 05:23:01 -05:00
parent 24ce746b91
commit bf439043cf
8 changed files with 237 additions and 2 deletions
+10
View File
@@ -8,6 +8,9 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'
import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
export const observationHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -37,6 +40,13 @@ export const observationHandler: EventHandler = {
throw new Error(`Missing cwd in PostToolUse hook input for session ${sessionId}, tool ${toolName}`);
}
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
logger.debug('HOOK', 'Project excluded from tracking, skipping observation', { cwd, toolName });
return { continue: true, suppressOutput: true };
}
// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, {
method: 'POST',
+10
View File
@@ -9,6 +9,9 @@ import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js
import { getProjectName } from '../../utils/project-name.js';
import { logger } from '../../utils/logger.js';
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
import { isProjectExcluded } from '../../utils/project-filter.js';
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
export const sessionInitHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
@@ -21,6 +24,13 @@ export const sessionInitHandler: EventHandler = {
const { sessionId, cwd, prompt: rawPrompt } = input;
// Check if project is excluded from tracking
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
logger.info('HOOK', 'Project excluded from tracking', { cwd });
return { continue: true, suppressOutput: true };
}
// Handle image-only prompts (where text prompt is empty/undefined)
// Use placeholder so sessions still get created and tracked for memory
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
+6
View File
@@ -52,6 +52,9 @@ export interface SettingsDefaults {
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: string;
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: string;
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: string;
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation
}
export class SettingsDefaultsManager {
@@ -98,6 +101,9 @@ export class SettingsDefaultsManager {
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
CLAUDE_MEM_FOLDER_CLAUDEMD_ENABLED: 'false',
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation
};
/**
+4
View File
@@ -36,4 +36,8 @@ export const DEFAULT_SETTINGS = {
// Feature Toggles
CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY: 'true',
CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE: 'false',
// Exclusion Settings
CLAUDE_MEM_EXCLUDED_PROJECTS: '',
CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]',
} as const;
+37 -1
View File
@@ -287,6 +287,26 @@ function isProjectRoot(folderPath: string): boolean {
return existsSync(gitPath);
}
/**
* Check if a folder path is excluded from CLAUDE.md generation.
* A folder is excluded if it matches or is within any path in the exclude list.
*
* @param folderPath - Absolute path to check
* @param excludePaths - Array of paths to exclude
* @returns true if folder should be excluded
*/
function isExcludedFolder(folderPath: string, excludePaths: string[]): boolean {
const normalizedFolder = path.resolve(folderPath);
for (const excludePath of excludePaths) {
const normalizedExclude = path.resolve(excludePath);
if (normalizedFolder === normalizedExclude ||
normalizedFolder.startsWith(normalizedExclude + path.sep)) {
return true;
}
}
return false;
}
/**
* Update CLAUDE.md files for folders containing the given files.
* Fetches timeline from worker API and writes formatted content.
@@ -304,10 +324,21 @@ export async function updateFolderClaudeMdFiles(
port: number,
projectRoot?: string
): Promise<void> {
// Load settings to get configurable observation limit
// Load settings to get configurable observation limit and exclude list
const settings = SettingsDefaultsManager.loadFromFile(SETTINGS_PATH);
const limit = parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10) || 50;
// Parse exclude paths from settings
let folderMdExcludePaths: string[] = [];
try {
const parsed = JSON.parse(settings.CLAUDE_MEM_FOLDER_MD_EXCLUDE || '[]');
if (Array.isArray(parsed)) {
folderMdExcludePaths = parsed.filter((p): p is string => typeof p === 'string');
}
} catch {
logger.warn('FOLDER_INDEX', 'Failed to parse CLAUDE_MEM_FOLDER_MD_EXCLUDE setting');
}
// Track folders containing CLAUDE.md files that were read/modified in this observation.
// We must NOT update these - it would cause "file modified since read" errors in Claude Code.
// See: https://github.com/thedotmack/claude-mem/issues/859
@@ -362,6 +393,11 @@ export async function updateFolderClaudeMdFiles(
logger.debug('FOLDER_INDEX', 'Skipping folder with active CLAUDE.md to avoid race condition', { folderPath });
continue;
}
// Skip folders in user-configured exclude list
if (folderMdExcludePaths.length > 0 && isExcludedFolder(folderPath, folderMdExcludePaths)) {
logger.debug('FOLDER_INDEX', 'Skipping excluded folder', { folderPath });
continue;
}
folderPaths.add(folderPath);
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Project Filter Utility
*
* Provides glob-based path matching for project exclusion.
* Supports: ~ (home), * (any chars except /), ** (any path), ? (single char)
*/
import { homedir } from 'os';
/**
* Convert a glob pattern to a regular expression
* Supports: ~ (home dir), * (any non-slash), ** (any path), ? (single char)
*/
function globToRegex(pattern: string): RegExp {
// Expand ~ to home directory
let expanded = pattern.startsWith('~')
? homedir() + pattern.slice(1)
: pattern;
// Normalize path separators to forward slashes
expanded = expanded.replace(/\\/g, '/');
// Escape regex special characters except * and ?
let regex = expanded.replace(/[.+^${}()|[\]\\]/g, '\\$&');
// Convert glob patterns to regex:
// ** matches any path (including /)
// * matches any characters except /
// ? matches single character except /
regex = regex
.replace(/\*\*/g, '<<<GLOBSTAR>>>') // Temporary placeholder
.replace(/\*/g, '[^/]*') // * = any non-slash
.replace(/\?/g, '[^/]') // ? = single non-slash
.replace(/<<<GLOBSTAR>>>/g, '.*'); // ** = anything
return new RegExp(`^${regex}$`);
}
/**
* Check if a path matches any of the exclusion patterns
*
* @param projectPath - Current working directory (absolute path)
* @param exclusionPatterns - Comma-separated glob patterns (e.g., "~/kunden/*,/tmp/*")
* @returns true if path should be excluded
*/
export function isProjectExcluded(projectPath: string, exclusionPatterns: string): boolean {
if (!exclusionPatterns || !exclusionPatterns.trim()) {
return false;
}
// Normalize cwd path separators
const normalizedProjectPath = projectPath.replace(/\\/g, '/');
// Parse comma-separated patterns
const patternList = exclusionPatterns
.split(',')
.map(p => p.trim())
.filter(Boolean);
for (const pattern of patternList) {
try {
const regex = globToRegex(pattern);
if (regex.test(normalizedProjectPath)) {
return true;
}
} catch {
// Invalid pattern, skip it
continue;
}
}
return false;
}