source dump of claude code
at main 1479 lines 46 kB view raw
1/** 2 * Files are loaded in the following order: 3 * 4 * 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users 5 * 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects 6 * 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots) - Instructions checked into the codebase 7 * 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions 8 * 9 * Files are loaded in reverse order of priority, i.e. the latest files are highest priority 10 * with the model paying more attention to them. 11 * 12 * File discovery: 13 * - User memory is loaded from the user's home directory 14 * - Project and Local files are discovered by traversing from the current directory up to root 15 * - Files closer to the current directory have higher priority (loaded later) 16 * - CLAUDE.md, .claude/CLAUDE.md, and all .md files in .claude/rules/ are checked in each directory for Project memory 17 * 18 * Memory @include directive: 19 * - Memory files can include other files using @ notation 20 * - Syntax: @path, @./relative/path, @~/home/path, or @/absolute/path 21 * - @path (without prefix) is treated as a relative path (same as @./path) 22 * - Works in leaf text nodes only (not inside code blocks or code strings) 23 * - Included files are added as separate entries before the including file 24 * - Circular references are prevented by tracking processed files 25 * - Non-existent files are silently ignored 26 */ 27 28import { feature } from 'bun:bundle' 29import ignore from 'ignore' 30import memoize from 'lodash-es/memoize.js' 31import { Lexer } from 'marked' 32import { 33 basename, 34 dirname, 35 extname, 36 isAbsolute, 37 join, 38 parse, 39 relative, 40 sep, 41} from 'path' 42import picomatch from 'picomatch' 43import { logEvent } from 'src/services/analytics/index.js' 44import { 45 getAdditionalDirectoriesForClaudeMd, 46 getOriginalCwd, 47} from '../bootstrap/state.js' 48import { truncateEntrypointContent } from '../memdir/memdir.js' 49import { getAutoMemEntrypoint, isAutoMemoryEnabled } from '../memdir/paths.js' 50import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 51import { 52 getCurrentProjectConfig, 53 getManagedClaudeRulesDir, 54 getMemoryPath, 55 getUserClaudeRulesDir, 56} from './config.js' 57import { logForDebugging } from './debug.js' 58import { logForDiagnosticsNoPII } from './diagLogs.js' 59import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 60import { getErrnoCode } from './errors.js' 61import { normalizePathForComparison } from './file.js' 62import { cacheKeys, type FileStateCache } from './fileStateCache.js' 63import { 64 parseFrontmatter, 65 splitPathInFrontmatter, 66} from './frontmatterParser.js' 67import { getFsImplementation, safeResolvePath } from './fsOperations.js' 68import { findCanonicalGitRoot, findGitRoot } from './git.js' 69import { 70 executeInstructionsLoadedHooks, 71 hasInstructionsLoadedHook, 72 type InstructionsLoadReason, 73 type InstructionsMemoryType, 74} from './hooks.js' 75import type { MemoryType } from './memory/types.js' 76import { expandPath } from './path.js' 77import { pathInWorkingPath } from './permissions/filesystem.js' 78import { isSettingSourceEnabled } from './settings/constants.js' 79import { getInitialSettings } from './settings/settings.js' 80 81/* eslint-disable @typescript-eslint/no-require-imports */ 82const teamMemPaths = feature('TEAMMEM') 83 ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) 84 : null 85/* eslint-enable @typescript-eslint/no-require-imports */ 86 87let hasLoggedInitialLoad = false 88 89const MEMORY_INSTRUCTION_PROMPT = 90 'Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.' 91// Recommended max character count for a memory file 92export const MAX_MEMORY_CHARACTER_COUNT = 40000 93 94// File extensions that are allowed for @include directives 95// This prevents binary files (images, PDFs, etc.) from being loaded into memory 96const TEXT_FILE_EXTENSIONS = new Set([ 97 // Markdown and text 98 '.md', 99 '.txt', 100 '.text', 101 // Data formats 102 '.json', 103 '.yaml', 104 '.yml', 105 '.toml', 106 '.xml', 107 '.csv', 108 // Web 109 '.html', 110 '.htm', 111 '.css', 112 '.scss', 113 '.sass', 114 '.less', 115 // JavaScript/TypeScript 116 '.js', 117 '.ts', 118 '.tsx', 119 '.jsx', 120 '.mjs', 121 '.cjs', 122 '.mts', 123 '.cts', 124 // Python 125 '.py', 126 '.pyi', 127 '.pyw', 128 // Ruby 129 '.rb', 130 '.erb', 131 '.rake', 132 // Go 133 '.go', 134 // Rust 135 '.rs', 136 // Java/Kotlin/Scala 137 '.java', 138 '.kt', 139 '.kts', 140 '.scala', 141 // C/C++ 142 '.c', 143 '.cpp', 144 '.cc', 145 '.cxx', 146 '.h', 147 '.hpp', 148 '.hxx', 149 // C# 150 '.cs', 151 // Swift 152 '.swift', 153 // Shell 154 '.sh', 155 '.bash', 156 '.zsh', 157 '.fish', 158 '.ps1', 159 '.bat', 160 '.cmd', 161 // Config 162 '.env', 163 '.ini', 164 '.cfg', 165 '.conf', 166 '.config', 167 '.properties', 168 // Database 169 '.sql', 170 '.graphql', 171 '.gql', 172 // Protocol 173 '.proto', 174 // Frontend frameworks 175 '.vue', 176 '.svelte', 177 '.astro', 178 // Templating 179 '.ejs', 180 '.hbs', 181 '.pug', 182 '.jade', 183 // Other languages 184 '.php', 185 '.pl', 186 '.pm', 187 '.lua', 188 '.r', 189 '.R', 190 '.dart', 191 '.ex', 192 '.exs', 193 '.erl', 194 '.hrl', 195 '.clj', 196 '.cljs', 197 '.cljc', 198 '.edn', 199 '.hs', 200 '.lhs', 201 '.elm', 202 '.ml', 203 '.mli', 204 '.f', 205 '.f90', 206 '.f95', 207 '.for', 208 // Build files 209 '.cmake', 210 '.make', 211 '.makefile', 212 '.gradle', 213 '.sbt', 214 // Documentation 215 '.rst', 216 '.adoc', 217 '.asciidoc', 218 '.org', 219 '.tex', 220 '.latex', 221 // Lock files (often text-based) 222 '.lock', 223 // Misc 224 '.log', 225 '.diff', 226 '.patch', 227]) 228 229export type MemoryFileInfo = { 230 path: string 231 type: MemoryType 232 content: string 233 parent?: string // Path of the file that included this one 234 globs?: string[] // Glob patterns for file paths this rule applies to 235 // True when auto-injection transformed `content` (stripped HTML comments, 236 // stripped frontmatter, truncated MEMORY.md) such that it no longer matches 237 // the bytes on disk. When set, `rawContent` holds the unmodified disk bytes 238 // so callers can cache a `isPartialView` readFileState entry — presence in 239 // cache provides dedup + change detection, but Edit/Write still require an 240 // explicit Read before proceeding. 241 contentDiffersFromDisk?: boolean 242 rawContent?: string 243} 244 245function pathInOriginalCwd(path: string): boolean { 246 return pathInWorkingPath(path, getOriginalCwd()) 247} 248 249/** 250 * Parses raw content to extract both content and glob patterns from frontmatter 251 * @param rawContent Raw file content with frontmatter 252 * @returns Object with content and globs (undefined if no paths or match-all pattern) 253 */ 254function parseFrontmatterPaths(rawContent: string): { 255 content: string 256 paths?: string[] 257} { 258 const { frontmatter, content } = parseFrontmatter(rawContent) 259 260 if (!frontmatter.paths) { 261 return { content } 262 } 263 264 const patterns = splitPathInFrontmatter(frontmatter.paths) 265 .map(pattern => { 266 // Remove /** suffix - ignore library treats 'path' as matching both 267 // the path itself and everything inside it 268 return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern 269 }) 270 .filter((p: string) => p.length > 0) 271 272 // If all patterns are ** (match-all), treat as no globs (undefined) 273 // This means the file applies to all paths 274 if (patterns.length === 0 || patterns.every((p: string) => p === '**')) { 275 return { content } 276 } 277 278 return { content, paths: patterns } 279} 280 281/** 282 * Strip block-level HTML comments (<!-- ... -->) from markdown content. 283 * 284 * Uses the marked lexer to identify comments at the block level only, so 285 * comments inside inline code spans and fenced code blocks are preserved. 286 * Inline HTML comments inside a paragraph are also left intact; the intended 287 * use case is authorial notes that occupy their own lines. 288 * 289 * Unclosed comments (`<!--` with no matching `-->`) are left in place so a 290 * typo doesn't silently swallow the rest of the file. 291 */ 292export function stripHtmlComments(content: string): { 293 content: string 294 stripped: boolean 295} { 296 if (!content.includes('<!--')) { 297 return { content, stripped: false } 298 } 299 // gfm:false is fine here — html-block detection is a CommonMark rule. 300 return stripHtmlCommentsFromTokens(new Lexer({ gfm: false }).lex(content)) 301} 302 303function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): { 304 content: string 305 stripped: boolean 306} { 307 let result = '' 308 let stripped = false 309 310 // A well-formed HTML comment span. Non-greedy so multiple comments on the 311 // same line are matched independently; [\s\S] to span newlines. 312 const commentSpan = /<!--[\s\S]*?-->/g 313 314 for (const token of tokens) { 315 if (token.type === 'html') { 316 const trimmed = token.raw.trimStart() 317 if (trimmed.startsWith('<!--') && trimmed.includes('-->')) { 318 // Per CommonMark, a type-2 HTML block ends at the *line* containing 319 // `-->`, so text after `-->` on that line is part of this token. 320 // Strip only the comment spans and keep any residual content. 321 const residue = token.raw.replace(commentSpan, '') 322 stripped = true 323 if (residue.trim().length > 0) { 324 // Residual content exists (e.g. `<!-- note --> Use bun`): keep it. 325 result += residue 326 } 327 continue 328 } 329 } 330 result += token.raw 331 } 332 333 return { content: result, stripped } 334} 335 336/** 337 * Parses raw memory file content into a MemoryFileInfo. Pure function — no I/O. 338 * 339 * When includeBasePath is given, @include paths are resolved in the same lex 340 * pass and returned alongside the parsed file (so processMemoryFile doesn't 341 * need to lex the same content a second time). 342 */ 343function parseMemoryFileContent( 344 rawContent: string, 345 filePath: string, 346 type: MemoryType, 347 includeBasePath?: string, 348): { info: MemoryFileInfo | null; includePaths: string[] } { 349 // Skip non-text files to prevent loading binary data (images, PDFs, etc.) into memory 350 const ext = extname(filePath).toLowerCase() 351 if (ext && !TEXT_FILE_EXTENSIONS.has(ext)) { 352 logForDebugging(`Skipping non-text file in @include: ${filePath}`) 353 return { info: null, includePaths: [] } 354 } 355 356 const { content: withoutFrontmatter, paths } = 357 parseFrontmatterPaths(rawContent) 358 359 // Lex once so strip and @include-extract share the same tokens. gfm:false 360 // is required by extract (so ~/path doesn't tokenize as strikethrough) and 361 // doesn't affect strip (html blocks are a CommonMark rule). 362 const hasComment = withoutFrontmatter.includes('<!--') 363 const tokens = 364 hasComment || includeBasePath !== undefined 365 ? new Lexer({ gfm: false }).lex(withoutFrontmatter) 366 : undefined 367 368 // Only rebuild via tokens when a comment actually needs stripping — 369 // marked normalises \r\n during lex, so round-tripping a CRLF file 370 // through token.raw would spuriously flip contentDiffersFromDisk. 371 const strippedContent = 372 hasComment && tokens 373 ? stripHtmlCommentsFromTokens(tokens).content 374 : withoutFrontmatter 375 376 const includePaths = 377 tokens && includeBasePath !== undefined 378 ? extractIncludePathsFromTokens(tokens, includeBasePath) 379 : [] 380 381 // Truncate MEMORY.md entrypoints to the line AND byte caps 382 let finalContent = strippedContent 383 if (type === 'AutoMem' || type === 'TeamMem') { 384 finalContent = truncateEntrypointContent(strippedContent).content 385 } 386 387 // Covers frontmatter strip, HTML comment strip, and MEMORY.md truncation 388 const contentDiffersFromDisk = finalContent !== rawContent 389 return { 390 info: { 391 path: filePath, 392 type, 393 content: finalContent, 394 globs: paths, 395 contentDiffersFromDisk, 396 rawContent: contentDiffersFromDisk ? rawContent : undefined, 397 }, 398 includePaths, 399 } 400} 401 402function handleMemoryFileReadError(error: unknown, filePath: string): void { 403 const code = getErrnoCode(error) 404 // ENOENT = file doesn't exist, EISDIR = is a directory — both expected 405 if (code === 'ENOENT' || code === 'EISDIR') { 406 return 407 } 408 // Log permission errors (EACCES) as they're actionable 409 if (code === 'EACCES') { 410 // Don't log the full file path to avoid PII/security issues 411 logEvent('tengu_claude_md_permission_error', { 412 is_access_error: 1, 413 has_home_dir: filePath.includes(getClaudeConfigHomeDir()) ? 1 : 0, 414 }) 415 } 416} 417 418/** 419 * Used by processMemoryFile → getMemoryFiles so the event loop stays 420 * responsive during the directory walk (many readFile attempts, most 421 * ENOENT). When includeBasePath is given, @include paths are resolved in 422 * the same lex pass and returned alongside the parsed file. 423 */ 424async function safelyReadMemoryFileAsync( 425 filePath: string, 426 type: MemoryType, 427 includeBasePath?: string, 428): Promise<{ info: MemoryFileInfo | null; includePaths: string[] }> { 429 try { 430 const fs = getFsImplementation() 431 const rawContent = await fs.readFile(filePath, { encoding: 'utf-8' }) 432 return parseMemoryFileContent(rawContent, filePath, type, includeBasePath) 433 } catch (error) { 434 handleMemoryFileReadError(error, filePath) 435 return { info: null, includePaths: [] } 436 } 437} 438 439type MarkdownToken = { 440 type: string 441 text?: string 442 href?: string 443 tokens?: MarkdownToken[] 444 raw?: string 445 items?: MarkdownToken[] 446} 447 448// Extract @path include references from pre-lexed tokens and resolve to 449// absolute paths. Skips html tokens so @paths inside block comments are 450// ignored — the caller may pass pre-strip tokens. 451function extractIncludePathsFromTokens( 452 tokens: ReturnType<Lexer['lex']>, 453 basePath: string, 454): string[] { 455 const absolutePaths = new Set<string>() 456 457 // Extract @paths from a text string and add resolved paths to absolutePaths. 458 function extractPathsFromText(textContent: string) { 459 const includeRegex = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g 460 let match 461 while ((match = includeRegex.exec(textContent)) !== null) { 462 let path = match[1] 463 if (!path) continue 464 465 // Strip fragment identifiers (#heading, #section-name, etc.) 466 const hashIndex = path.indexOf('#') 467 if (hashIndex !== -1) { 468 path = path.substring(0, hashIndex) 469 } 470 if (!path) continue 471 472 // Unescape the spaces in the path 473 path = path.replace(/\\ /g, ' ') 474 475 // Accept @path, @./path, @~/path, or @/path 476 if (path) { 477 const isValidPath = 478 path.startsWith('./') || 479 path.startsWith('~/') || 480 (path.startsWith('/') && path !== '/') || 481 (!path.startsWith('@') && 482 !path.match(/^[#%^&*()]+/) && 483 path.match(/^[a-zA-Z0-9._-]/)) 484 485 if (isValidPath) { 486 const resolvedPath = expandPath(path, dirname(basePath)) 487 absolutePaths.add(resolvedPath) 488 } 489 } 490 } 491 } 492 493 // Recursively process elements to find text nodes 494 function processElements(elements: MarkdownToken[]) { 495 for (const element of elements) { 496 if (element.type === 'code' || element.type === 'codespan') { 497 continue 498 } 499 500 // For html tokens that contain comments, strip the comment spans and 501 // check the residual for @paths (e.g. `<!-- note --> @./file.md`). 502 // Other html tokens (non-comment tags) are skipped entirely. 503 if (element.type === 'html') { 504 const raw = element.raw || '' 505 const trimmed = raw.trimStart() 506 if (trimmed.startsWith('<!--') && trimmed.includes('-->')) { 507 const commentSpan = /<!--[\s\S]*?-->/g 508 const residue = raw.replace(commentSpan, '') 509 if (residue.trim().length > 0) { 510 extractPathsFromText(residue) 511 } 512 } 513 continue 514 } 515 516 // Process text nodes 517 if (element.type === 'text') { 518 extractPathsFromText(element.text || '') 519 } 520 521 // Recurse into children tokens 522 if (element.tokens) { 523 processElements(element.tokens) 524 } 525 526 // Special handling for list structures 527 if (element.items) { 528 processElements(element.items) 529 } 530 } 531 } 532 533 processElements(tokens as MarkdownToken[]) 534 return [...absolutePaths] 535} 536 537const MAX_INCLUDE_DEPTH = 5 538 539/** 540 * Checks whether a CLAUDE.md file path is excluded by the claudeMdExcludes setting. 541 * Only applies to User, Project, and Local memory types. 542 * Managed, AutoMem, and TeamMem types are never excluded. 543 * 544 * Matches both the original path and the realpath-resolved path to handle symlinks 545 * (e.g., /tmp -> /private/tmp on macOS). 546 */ 547function isClaudeMdExcluded(filePath: string, type: MemoryType): boolean { 548 if (type !== 'User' && type !== 'Project' && type !== 'Local') { 549 return false 550 } 551 552 const patterns = getInitialSettings().claudeMdExcludes 553 if (!patterns || patterns.length === 0) { 554 return false 555 } 556 557 const matchOpts = { dot: true } 558 const normalizedPath = filePath.replaceAll('\\', '/') 559 560 // Build an expanded pattern list that includes realpath-resolved versions of 561 // absolute patterns. This handles symlinks like /tmp -> /private/tmp on macOS: 562 // the user writes "/tmp/project/CLAUDE.md" in their exclude, but the system 563 // resolves the CWD to "/private/tmp/project/...", so the file path uses the 564 // real path. By resolving the patterns too, both sides match. 565 const expandedPatterns = resolveExcludePatterns(patterns).filter( 566 p => p.length > 0, 567 ) 568 if (expandedPatterns.length === 0) { 569 return false 570 } 571 572 return picomatch.isMatch(normalizedPath, expandedPatterns, matchOpts) 573} 574 575/** 576 * Expands exclude patterns by resolving symlinks in absolute path prefixes. 577 * For each absolute pattern (starting with /), tries to resolve the longest 578 * existing directory prefix via realpathSync and adds the resolved version. 579 * Glob patterns (containing *) have their static prefix resolved. 580 */ 581function resolveExcludePatterns(patterns: string[]): string[] { 582 const fs = getFsImplementation() 583 const expanded: string[] = patterns.map(p => p.replaceAll('\\', '/')) 584 585 for (const normalized of expanded) { 586 // Only resolve absolute patterns — glob-only patterns like "**/*.md" don't have 587 // a filesystem prefix to resolve 588 if (!normalized.startsWith('/')) { 589 continue 590 } 591 592 // Find the static prefix before any glob characters 593 const globStart = normalized.search(/[*?{[]/) 594 const staticPrefix = 595 globStart === -1 ? normalized : normalized.slice(0, globStart) 596 const dirToResolve = dirname(staticPrefix) 597 598 try { 599 // sync IO: called from sync context (isClaudeMdExcluded -> processMemoryFile -> getMemoryFiles) 600 const resolvedDir = fs.realpathSync(dirToResolve).replaceAll('\\', '/') 601 if (resolvedDir !== dirToResolve) { 602 const resolvedPattern = 603 resolvedDir + normalized.slice(dirToResolve.length) 604 expanded.push(resolvedPattern) 605 } 606 } catch { 607 // Directory doesn't exist; skip resolution for this pattern 608 } 609 } 610 611 return expanded 612} 613 614/** 615 * Recursively processes a memory file and all its @include references 616 * Returns an array of MemoryFileInfo objects with includes first, then main file 617 */ 618export async function processMemoryFile( 619 filePath: string, 620 type: MemoryType, 621 processedPaths: Set<string>, 622 includeExternal: boolean, 623 depth: number = 0, 624 parent?: string, 625): Promise<MemoryFileInfo[]> { 626 // Skip if already processed or max depth exceeded. 627 // Normalize paths for comparison to handle Windows drive letter casing 628 // differences (e.g., C:\Users vs c:\Users). 629 const normalizedPath = normalizePathForComparison(filePath) 630 if (processedPaths.has(normalizedPath) || depth >= MAX_INCLUDE_DEPTH) { 631 return [] 632 } 633 634 // Skip if path is excluded by claudeMdExcludes setting 635 if (isClaudeMdExcluded(filePath, type)) { 636 return [] 637 } 638 639 // Resolve symlink path early for @import resolution 640 const { resolvedPath, isSymlink } = safeResolvePath( 641 getFsImplementation(), 642 filePath, 643 ) 644 645 processedPaths.add(normalizedPath) 646 if (isSymlink) { 647 processedPaths.add(normalizePathForComparison(resolvedPath)) 648 } 649 650 const { info: memoryFile, includePaths: resolvedIncludePaths } = 651 await safelyReadMemoryFileAsync(filePath, type, resolvedPath) 652 if (!memoryFile || !memoryFile.content.trim()) { 653 return [] 654 } 655 656 // Add parent information 657 if (parent) { 658 memoryFile.parent = parent 659 } 660 661 const result: MemoryFileInfo[] = [] 662 663 // Add the main file first (parent before children) 664 result.push(memoryFile) 665 666 for (const resolvedIncludePath of resolvedIncludePaths) { 667 const isExternal = !pathInOriginalCwd(resolvedIncludePath) 668 if (isExternal && !includeExternal) { 669 continue 670 } 671 672 // Recursively process included files with this file as parent 673 const includedFiles = await processMemoryFile( 674 resolvedIncludePath, 675 type, 676 processedPaths, 677 includeExternal, 678 depth + 1, 679 filePath, // Pass current file as parent 680 ) 681 result.push(...includedFiles) 682 } 683 684 return result 685} 686 687/** 688 * Processes all .md files in the .claude/rules/ directory and its subdirectories 689 * @param rulesDir The path to the rules directory 690 * @param type Type of memory file (User, Project, Local) 691 * @param processedPaths Set of already processed file paths 692 * @param includeExternal Whether to include external files 693 * @param conditionalRule If true, only include files with frontmatter paths; if false, only include files without frontmatter paths 694 * @param visitedDirs Set of already visited directory real paths (for cycle detection) 695 * @returns Array of MemoryFileInfo objects 696 */ 697export async function processMdRules({ 698 rulesDir, 699 type, 700 processedPaths, 701 includeExternal, 702 conditionalRule, 703 visitedDirs = new Set(), 704}: { 705 rulesDir: string 706 type: MemoryType 707 processedPaths: Set<string> 708 includeExternal: boolean 709 conditionalRule: boolean 710 visitedDirs?: Set<string> 711}): Promise<MemoryFileInfo[]> { 712 if (visitedDirs.has(rulesDir)) { 713 return [] 714 } 715 716 try { 717 const fs = getFsImplementation() 718 719 const { resolvedPath: resolvedRulesDir, isSymlink } = safeResolvePath( 720 fs, 721 rulesDir, 722 ) 723 724 visitedDirs.add(rulesDir) 725 if (isSymlink) { 726 visitedDirs.add(resolvedRulesDir) 727 } 728 729 const result: MemoryFileInfo[] = [] 730 let entries: import('fs').Dirent[] 731 try { 732 entries = await fs.readdir(resolvedRulesDir) 733 } catch (e: unknown) { 734 const code = getErrnoCode(e) 735 if (code === 'ENOENT' || code === 'EACCES' || code === 'ENOTDIR') { 736 return [] 737 } 738 throw e 739 } 740 741 for (const entry of entries) { 742 const entryPath = join(rulesDir, entry.name) 743 const { resolvedPath: resolvedEntryPath, isSymlink } = safeResolvePath( 744 fs, 745 entryPath, 746 ) 747 748 // Use Dirent methods for non-symlinks to avoid extra stat calls. 749 // For symlinks, we need stat to determine what the target is. 750 const stats = isSymlink ? await fs.stat(resolvedEntryPath) : null 751 const isDirectory = stats ? stats.isDirectory() : entry.isDirectory() 752 const isFile = stats ? stats.isFile() : entry.isFile() 753 754 if (isDirectory) { 755 result.push( 756 ...(await processMdRules({ 757 rulesDir: resolvedEntryPath, 758 type, 759 processedPaths, 760 includeExternal, 761 conditionalRule, 762 visitedDirs, 763 })), 764 ) 765 } else if (isFile && entry.name.endsWith('.md')) { 766 const files = await processMemoryFile( 767 resolvedEntryPath, 768 type, 769 processedPaths, 770 includeExternal, 771 ) 772 result.push( 773 ...files.filter(f => (conditionalRule ? f.globs : !f.globs)), 774 ) 775 } 776 } 777 778 return result 779 } catch (error) { 780 if (error instanceof Error && error.message.includes('EACCES')) { 781 logEvent('tengu_claude_rules_md_permission_error', { 782 is_access_error: 1, 783 has_home_dir: rulesDir.includes(getClaudeConfigHomeDir()) ? 1 : 0, 784 }) 785 } 786 return [] 787 } 788} 789 790export const getMemoryFiles = memoize( 791 async (forceIncludeExternal: boolean = false): Promise<MemoryFileInfo[]> => { 792 const startTime = Date.now() 793 logForDiagnosticsNoPII('info', 'memory_files_started') 794 795 const result: MemoryFileInfo[] = [] 796 const processedPaths = new Set<string>() 797 const config = getCurrentProjectConfig() 798 const includeExternal = 799 forceIncludeExternal || 800 config.hasClaudeMdExternalIncludesApproved || 801 false 802 803 // Process Managed file first (always loaded - policy settings) 804 const managedClaudeMd = getMemoryPath('Managed') 805 result.push( 806 ...(await processMemoryFile( 807 managedClaudeMd, 808 'Managed', 809 processedPaths, 810 includeExternal, 811 )), 812 ) 813 // Process Managed .claude/rules/*.md files 814 const managedClaudeRulesDir = getManagedClaudeRulesDir() 815 result.push( 816 ...(await processMdRules({ 817 rulesDir: managedClaudeRulesDir, 818 type: 'Managed', 819 processedPaths, 820 includeExternal, 821 conditionalRule: false, 822 })), 823 ) 824 825 // Process User file (only if userSettings is enabled) 826 if (isSettingSourceEnabled('userSettings')) { 827 const userClaudeMd = getMemoryPath('User') 828 result.push( 829 ...(await processMemoryFile( 830 userClaudeMd, 831 'User', 832 processedPaths, 833 true, // User memory can always include external files 834 )), 835 ) 836 // Process User ~/.claude/rules/*.md files 837 const userClaudeRulesDir = getUserClaudeRulesDir() 838 result.push( 839 ...(await processMdRules({ 840 rulesDir: userClaudeRulesDir, 841 type: 'User', 842 processedPaths, 843 includeExternal: true, 844 conditionalRule: false, 845 })), 846 ) 847 } 848 849 // Then process Project and Local files 850 const dirs: string[] = [] 851 const originalCwd = getOriginalCwd() 852 let currentDir = originalCwd 853 854 while (currentDir !== parse(currentDir).root) { 855 dirs.push(currentDir) 856 currentDir = dirname(currentDir) 857 } 858 859 // When running from a git worktree nested inside its main repo (e.g., 860 // .claude/worktrees/<name>/ from `claude -w`), the upward walk passes 861 // through both the worktree root and the main repo root. Both contain 862 // checked-in files like CLAUDE.md and .claude/rules/*.md, so the same 863 // content gets loaded twice. Skip Project-type (checked-in) files from 864 // directories above the worktree but within the main repo — the worktree 865 // already has its own checkout. CLAUDE.local.md is gitignored so it only 866 // exists in the main repo and is still loaded. 867 // See: https://github.com/anthropics/claude-code/issues/29599 868 const gitRoot = findGitRoot(originalCwd) 869 const canonicalRoot = findCanonicalGitRoot(originalCwd) 870 const isNestedWorktree = 871 gitRoot !== null && 872 canonicalRoot !== null && 873 normalizePathForComparison(gitRoot) !== 874 normalizePathForComparison(canonicalRoot) && 875 pathInWorkingPath(gitRoot, canonicalRoot) 876 877 // Process from root downward to CWD 878 for (const dir of dirs.reverse()) { 879 // In a nested worktree, skip checked-in files from the main repo's 880 // working tree (dirs inside canonicalRoot but outside the worktree). 881 const skipProject = 882 isNestedWorktree && 883 pathInWorkingPath(dir, canonicalRoot) && 884 !pathInWorkingPath(dir, gitRoot) 885 886 // Try reading CLAUDE.md (Project) - only if projectSettings is enabled 887 if (isSettingSourceEnabled('projectSettings') && !skipProject) { 888 const projectPath = join(dir, 'CLAUDE.md') 889 result.push( 890 ...(await processMemoryFile( 891 projectPath, 892 'Project', 893 processedPaths, 894 includeExternal, 895 )), 896 ) 897 898 // Try reading .claude/CLAUDE.md (Project) 899 const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 900 result.push( 901 ...(await processMemoryFile( 902 dotClaudePath, 903 'Project', 904 processedPaths, 905 includeExternal, 906 )), 907 ) 908 909 // Try reading .claude/rules/*.md files (Project) 910 const rulesDir = join(dir, '.claude', 'rules') 911 result.push( 912 ...(await processMdRules({ 913 rulesDir, 914 type: 'Project', 915 processedPaths, 916 includeExternal, 917 conditionalRule: false, 918 })), 919 ) 920 } 921 922 // Try reading CLAUDE.local.md (Local) - only if localSettings is enabled 923 if (isSettingSourceEnabled('localSettings')) { 924 const localPath = join(dir, 'CLAUDE.local.md') 925 result.push( 926 ...(await processMemoryFile( 927 localPath, 928 'Local', 929 processedPaths, 930 includeExternal, 931 )), 932 ) 933 } 934 } 935 936 // Process CLAUDE.md from additional directories (--add-dir) if env var is enabled 937 // This is controlled by CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and defaults to off 938 // Note: we don't check isSettingSourceEnabled('projectSettings') here because --add-dir 939 // is an explicit user action and the SDK defaults settingSources to [] when not specified 940 if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) { 941 const additionalDirs = getAdditionalDirectoriesForClaudeMd() 942 for (const dir of additionalDirs) { 943 // Try reading CLAUDE.md from the additional directory 944 const projectPath = join(dir, 'CLAUDE.md') 945 result.push( 946 ...(await processMemoryFile( 947 projectPath, 948 'Project', 949 processedPaths, 950 includeExternal, 951 )), 952 ) 953 954 // Try reading .claude/CLAUDE.md from the additional directory 955 const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 956 result.push( 957 ...(await processMemoryFile( 958 dotClaudePath, 959 'Project', 960 processedPaths, 961 includeExternal, 962 )), 963 ) 964 965 // Try reading .claude/rules/*.md files from the additional directory 966 const rulesDir = join(dir, '.claude', 'rules') 967 result.push( 968 ...(await processMdRules({ 969 rulesDir, 970 type: 'Project', 971 processedPaths, 972 includeExternal, 973 conditionalRule: false, 974 })), 975 ) 976 } 977 } 978 979 // Memdir entrypoint (memory.md) - only if feature is on and file exists 980 if (isAutoMemoryEnabled()) { 981 const { info: memdirEntry } = await safelyReadMemoryFileAsync( 982 getAutoMemEntrypoint(), 983 'AutoMem', 984 ) 985 if (memdirEntry) { 986 const normalizedPath = normalizePathForComparison(memdirEntry.path) 987 if (!processedPaths.has(normalizedPath)) { 988 processedPaths.add(normalizedPath) 989 result.push(memdirEntry) 990 } 991 } 992 } 993 994 // Team memory entrypoint - only if feature is on and file exists 995 if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { 996 const { info: teamMemEntry } = await safelyReadMemoryFileAsync( 997 teamMemPaths!.getTeamMemEntrypoint(), 998 'TeamMem', 999 ) 1000 if (teamMemEntry) { 1001 const normalizedPath = normalizePathForComparison(teamMemEntry.path) 1002 if (!processedPaths.has(normalizedPath)) { 1003 processedPaths.add(normalizedPath) 1004 result.push(teamMemEntry) 1005 } 1006 } 1007 } 1008 1009 const totalContentLength = result.reduce( 1010 (sum, f) => sum + f.content.length, 1011 0, 1012 ) 1013 1014 logForDiagnosticsNoPII('info', 'memory_files_completed', { 1015 duration_ms: Date.now() - startTime, 1016 file_count: result.length, 1017 total_content_length: totalContentLength, 1018 }) 1019 1020 const typeCounts: Record<string, number> = {} 1021 for (const f of result) { 1022 typeCounts[f.type] = (typeCounts[f.type] ?? 0) + 1 1023 } 1024 1025 if (!hasLoggedInitialLoad) { 1026 hasLoggedInitialLoad = true 1027 logEvent('tengu_claudemd__initial_load', { 1028 file_count: result.length, 1029 total_content_length: totalContentLength, 1030 user_count: typeCounts['User'] ?? 0, 1031 project_count: typeCounts['Project'] ?? 0, 1032 local_count: typeCounts['Local'] ?? 0, 1033 managed_count: typeCounts['Managed'] ?? 0, 1034 automem_count: typeCounts['AutoMem'] ?? 0, 1035 ...(feature('TEAMMEM') 1036 ? { teammem_count: typeCounts['TeamMem'] ?? 0 } 1037 : {}), 1038 duration_ms: Date.now() - startTime, 1039 }) 1040 } 1041 1042 // Fire InstructionsLoaded hook for each instruction file loaded 1043 // (fire-and-forget, audit/observability only). 1044 // AutoMem/TeamMem are intentionally excluded — they're a separate 1045 // memory system, not "instructions" in the CLAUDE.md/rules sense. 1046 // Gated on !forceIncludeExternal: the forceIncludeExternal=true variant 1047 // is only used by getExternalClaudeMdIncludes() for approval checks, not 1048 // for building context — firing the hook there would double-fire on startup. 1049 // The one-shot flag is consumed on every !forceIncludeExternal cache miss 1050 // (NOT gated on hasInstructionsLoadedHook) so the flag is released even 1051 // when no hook is configured — otherwise a mid-session hook registration 1052 // followed by a direct .cache.clear() would spuriously fire with a stale 1053 // 'session_start' reason. 1054 if (!forceIncludeExternal) { 1055 const eagerLoadReason = consumeNextEagerLoadReason() 1056 if (eagerLoadReason !== undefined && hasInstructionsLoadedHook()) { 1057 for (const file of result) { 1058 if (!isInstructionsMemoryType(file.type)) continue 1059 const loadReason = file.parent ? 'include' : eagerLoadReason 1060 void executeInstructionsLoadedHooks( 1061 file.path, 1062 file.type, 1063 loadReason, 1064 { 1065 globs: file.globs, 1066 parentFilePath: file.parent, 1067 }, 1068 ) 1069 } 1070 } 1071 } 1072 1073 return result 1074 }, 1075) 1076 1077function isInstructionsMemoryType( 1078 type: MemoryType, 1079): type is InstructionsMemoryType { 1080 return ( 1081 type === 'User' || 1082 type === 'Project' || 1083 type === 'Local' || 1084 type === 'Managed' 1085 ) 1086} 1087 1088// Load reason to report for top-level (non-included) files on the next eager 1089// getMemoryFiles() pass. Set to 'compact' by resetGetMemoryFilesCache when 1090// compaction clears the cache, so the InstructionsLoaded hook reports the 1091// reload correctly instead of misreporting it as 'session_start'. One-shot: 1092// reset to 'session_start' after being read. 1093let nextEagerLoadReason: InstructionsLoadReason = 'session_start' 1094 1095// Whether the InstructionsLoaded hook should fire on the next cache miss. 1096// true initially (for session_start), consumed after firing, re-enabled only 1097// by resetGetMemoryFilesCache(). Callers that only need cache invalidation 1098// for correctness (e.g. worktree enter/exit, settings sync, /memory dialog) 1099// should use clearMemoryFileCaches() instead to avoid spurious hook fires. 1100let shouldFireHook = true 1101 1102function consumeNextEagerLoadReason(): InstructionsLoadReason | undefined { 1103 if (!shouldFireHook) return undefined 1104 shouldFireHook = false 1105 const reason = nextEagerLoadReason 1106 nextEagerLoadReason = 'session_start' 1107 return reason 1108} 1109 1110/** 1111 * Clears the getMemoryFiles memoize cache 1112 * without firing the InstructionsLoaded hook. 1113 * 1114 * Use this for cache invalidation that is purely for correctness (e.g. 1115 * worktree enter/exit, settings sync, /memory dialog). For events that 1116 * represent instructions actually being reloaded into context (e.g. 1117 * compaction), use resetGetMemoryFilesCache() instead. 1118 */ 1119export function clearMemoryFileCaches(): void { 1120 // ?.cache because tests spyOn this, which replaces the memoize wrapper. 1121 getMemoryFiles.cache?.clear?.() 1122} 1123 1124export function resetGetMemoryFilesCache( 1125 reason: InstructionsLoadReason = 'session_start', 1126): void { 1127 nextEagerLoadReason = reason 1128 shouldFireHook = true 1129 clearMemoryFileCaches() 1130} 1131 1132export function getLargeMemoryFiles(files: MemoryFileInfo[]): MemoryFileInfo[] { 1133 return files.filter(f => f.content.length > MAX_MEMORY_CHARACTER_COUNT) 1134} 1135 1136/** 1137 * When tengu_moth_copse is on, the findRelevantMemories prefetch surfaces 1138 * memory files via attachments, so the MEMORY.md index is no longer injected 1139 * into the system prompt. Callsites that care about "what's actually in 1140 * context" (context builder, /context viz) should filter through this. 1141 */ 1142export function filterInjectedMemoryFiles( 1143 files: MemoryFileInfo[], 1144): MemoryFileInfo[] { 1145 const skipMemoryIndex = getFeatureValue_CACHED_MAY_BE_STALE( 1146 'tengu_moth_copse', 1147 false, 1148 ) 1149 if (!skipMemoryIndex) return files 1150 return files.filter(f => f.type !== 'AutoMem' && f.type !== 'TeamMem') 1151} 1152 1153export const getClaudeMds = ( 1154 memoryFiles: MemoryFileInfo[], 1155 filter?: (type: MemoryType) => boolean, 1156): string => { 1157 const memories: string[] = [] 1158 const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE( 1159 'tengu_paper_halyard', 1160 false, 1161 ) 1162 1163 for (const file of memoryFiles) { 1164 if (filter && !filter(file.type)) continue 1165 if (skipProjectLevel && (file.type === 'Project' || file.type === 'Local')) 1166 continue 1167 if (file.content) { 1168 const description = 1169 file.type === 'Project' 1170 ? ' (project instructions, checked into the codebase)' 1171 : file.type === 'Local' 1172 ? " (user's private project instructions, not checked in)" 1173 : feature('TEAMMEM') && file.type === 'TeamMem' 1174 ? ' (shared team memory, synced across the organization)' 1175 : file.type === 'AutoMem' 1176 ? " (user's auto-memory, persists across conversations)" 1177 : " (user's private global instructions for all projects)" 1178 1179 const content = file.content.trim() 1180 if (feature('TEAMMEM') && file.type === 'TeamMem') { 1181 memories.push( 1182 `Contents of ${file.path}${description}:\n\n<team-memory-content source="shared">\n${content}\n</team-memory-content>`, 1183 ) 1184 } else { 1185 memories.push(`Contents of ${file.path}${description}:\n\n${content}`) 1186 } 1187 } 1188 } 1189 1190 if (memories.length === 0) { 1191 return '' 1192 } 1193 1194 return `${MEMORY_INSTRUCTION_PROMPT}\n\n${memories.join('\n\n')}` 1195} 1196 1197/** 1198 * Gets managed and user conditional rules that match the target path. 1199 * This is the first phase of nested memory loading. 1200 * 1201 * @param targetPath The target file path to match against glob patterns 1202 * @param processedPaths Set of already processed file paths (will be mutated) 1203 * @returns Array of MemoryFileInfo objects for matching conditional rules 1204 */ 1205export async function getManagedAndUserConditionalRules( 1206 targetPath: string, 1207 processedPaths: Set<string>, 1208): Promise<MemoryFileInfo[]> { 1209 const result: MemoryFileInfo[] = [] 1210 1211 // Process Managed conditional .claude/rules/*.md files 1212 const managedClaudeRulesDir = getManagedClaudeRulesDir() 1213 result.push( 1214 ...(await processConditionedMdRules( 1215 targetPath, 1216 managedClaudeRulesDir, 1217 'Managed', 1218 processedPaths, 1219 false, 1220 )), 1221 ) 1222 1223 if (isSettingSourceEnabled('userSettings')) { 1224 // Process User conditional .claude/rules/*.md files 1225 const userClaudeRulesDir = getUserClaudeRulesDir() 1226 result.push( 1227 ...(await processConditionedMdRules( 1228 targetPath, 1229 userClaudeRulesDir, 1230 'User', 1231 processedPaths, 1232 true, 1233 )), 1234 ) 1235 } 1236 1237 return result 1238} 1239 1240/** 1241 * Gets memory files for a single nested directory (between CWD and target). 1242 * Loads CLAUDE.md, unconditional rules, and conditional rules for that directory. 1243 * 1244 * @param dir The directory to process 1245 * @param targetPath The target file path (for conditional rule matching) 1246 * @param processedPaths Set of already processed file paths (will be mutated) 1247 * @returns Array of MemoryFileInfo objects 1248 */ 1249export async function getMemoryFilesForNestedDirectory( 1250 dir: string, 1251 targetPath: string, 1252 processedPaths: Set<string>, 1253): Promise<MemoryFileInfo[]> { 1254 const result: MemoryFileInfo[] = [] 1255 1256 // Process project memory files (CLAUDE.md and .claude/CLAUDE.md) 1257 if (isSettingSourceEnabled('projectSettings')) { 1258 const projectPath = join(dir, 'CLAUDE.md') 1259 result.push( 1260 ...(await processMemoryFile( 1261 projectPath, 1262 'Project', 1263 processedPaths, 1264 false, 1265 )), 1266 ) 1267 const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 1268 result.push( 1269 ...(await processMemoryFile( 1270 dotClaudePath, 1271 'Project', 1272 processedPaths, 1273 false, 1274 )), 1275 ) 1276 } 1277 1278 // Process local memory file (CLAUDE.local.md) 1279 if (isSettingSourceEnabled('localSettings')) { 1280 const localPath = join(dir, 'CLAUDE.local.md') 1281 result.push( 1282 ...(await processMemoryFile(localPath, 'Local', processedPaths, false)), 1283 ) 1284 } 1285 1286 const rulesDir = join(dir, '.claude', 'rules') 1287 1288 // Process project unconditional .claude/rules/*.md files, which were not eagerly loaded 1289 // Use a separate processedPaths set to avoid marking conditional rule files as processed 1290 const unconditionalProcessedPaths = new Set(processedPaths) 1291 result.push( 1292 ...(await processMdRules({ 1293 rulesDir, 1294 type: 'Project', 1295 processedPaths: unconditionalProcessedPaths, 1296 includeExternal: false, 1297 conditionalRule: false, 1298 })), 1299 ) 1300 1301 // Process project conditional .claude/rules/*.md files 1302 result.push( 1303 ...(await processConditionedMdRules( 1304 targetPath, 1305 rulesDir, 1306 'Project', 1307 processedPaths, 1308 false, 1309 )), 1310 ) 1311 1312 // processedPaths must be seeded with unconditional paths for subsequent directories 1313 for (const path of unconditionalProcessedPaths) { 1314 processedPaths.add(path) 1315 } 1316 1317 return result 1318} 1319 1320/** 1321 * Gets conditional rules for a CWD-level directory (from root up to CWD). 1322 * Only processes conditional rules since unconditional rules are already loaded eagerly. 1323 * 1324 * @param dir The directory to process 1325 * @param targetPath The target file path (for conditional rule matching) 1326 * @param processedPaths Set of already processed file paths (will be mutated) 1327 * @returns Array of MemoryFileInfo objects 1328 */ 1329export async function getConditionalRulesForCwdLevelDirectory( 1330 dir: string, 1331 targetPath: string, 1332 processedPaths: Set<string>, 1333): Promise<MemoryFileInfo[]> { 1334 const rulesDir = join(dir, '.claude', 'rules') 1335 return processConditionedMdRules( 1336 targetPath, 1337 rulesDir, 1338 'Project', 1339 processedPaths, 1340 false, 1341 ) 1342} 1343 1344/** 1345 * Processes all .md files in the .claude/rules/ directory and its subdirectories, 1346 * filtering to only include files with frontmatter paths that match the target path 1347 * @param targetPath The file path to match against frontmatter glob patterns 1348 * @param rulesDir The path to the rules directory 1349 * @param type Type of memory file (User, Project, Local) 1350 * @param processedPaths Set of already processed file paths 1351 * @param includeExternal Whether to include external files 1352 * @returns Array of MemoryFileInfo objects that match the target path 1353 */ 1354export async function processConditionedMdRules( 1355 targetPath: string, 1356 rulesDir: string, 1357 type: MemoryType, 1358 processedPaths: Set<string>, 1359 includeExternal: boolean, 1360): Promise<MemoryFileInfo[]> { 1361 const conditionedRuleMdFiles = await processMdRules({ 1362 rulesDir, 1363 type, 1364 processedPaths, 1365 includeExternal, 1366 conditionalRule: true, 1367 }) 1368 1369 // Filter to only include files whose globs patterns match the targetPath 1370 return conditionedRuleMdFiles.filter(file => { 1371 if (!file.globs || file.globs.length === 0) { 1372 return false 1373 } 1374 1375 // For Project rules: glob patterns are relative to the directory containing .claude 1376 // For Managed/User rules: glob patterns are relative to the original CWD 1377 const baseDir = 1378 type === 'Project' 1379 ? dirname(dirname(rulesDir)) // Parent of .claude 1380 : getOriginalCwd() // Project root for managed/user rules 1381 1382 const relativePath = isAbsolute(targetPath) 1383 ? relative(baseDir, targetPath) 1384 : targetPath 1385 // ignore() throws on empty strings, paths escaping the base (../), 1386 // and absolute paths (Windows cross-drive relative() returns absolute). 1387 // Files outside baseDir can't match baseDir-relative globs anyway. 1388 if ( 1389 !relativePath || 1390 relativePath.startsWith('..') || 1391 isAbsolute(relativePath) 1392 ) { 1393 return false 1394 } 1395 return ignore().add(file.globs).ignores(relativePath) 1396 }) 1397} 1398 1399export type ExternalClaudeMdInclude = { 1400 path: string 1401 parent: string 1402} 1403 1404export function getExternalClaudeMdIncludes( 1405 files: MemoryFileInfo[], 1406): ExternalClaudeMdInclude[] { 1407 const externals: ExternalClaudeMdInclude[] = [] 1408 for (const file of files) { 1409 if (file.type !== 'User' && file.parent && !pathInOriginalCwd(file.path)) { 1410 externals.push({ path: file.path, parent: file.parent }) 1411 } 1412 } 1413 return externals 1414} 1415 1416export function hasExternalClaudeMdIncludes(files: MemoryFileInfo[]): boolean { 1417 return getExternalClaudeMdIncludes(files).length > 0 1418} 1419 1420export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boolean> { 1421 const config = getCurrentProjectConfig() 1422 if ( 1423 config.hasClaudeMdExternalIncludesApproved || 1424 config.hasClaudeMdExternalIncludesWarningShown 1425 ) { 1426 return false 1427 } 1428 1429 return hasExternalClaudeMdIncludes(await getMemoryFiles(true)) 1430} 1431 1432/** 1433 * Check if a file path is a memory file (CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md) 1434 */ 1435export function isMemoryFilePath(filePath: string): boolean { 1436 const name = basename(filePath) 1437 1438 // CLAUDE.md or CLAUDE.local.md anywhere 1439 if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') { 1440 return true 1441 } 1442 1443 // .md files in .claude/rules/ directories 1444 if ( 1445 name.endsWith('.md') && 1446 filePath.includes(`${sep}.claude${sep}rules${sep}`) 1447 ) { 1448 return true 1449 } 1450 1451 return false 1452} 1453 1454/** 1455 * Get all memory file paths from both standard discovery and readFileState. 1456 * Combines: 1457 * - getMemoryFiles() paths (CWD upward to root) 1458 * - readFileState paths matching memory patterns (includes child directories) 1459 */ 1460export function getAllMemoryFilePaths( 1461 files: MemoryFileInfo[], 1462 readFileState: FileStateCache, 1463): string[] { 1464 const paths = new Set<string>() 1465 for (const file of files) { 1466 if (file.content.trim().length > 0) { 1467 paths.add(file.path) 1468 } 1469 } 1470 1471 // Add memory files from readFileState (includes child directories) 1472 for (const filePath of cacheKeys(readFileState)) { 1473 if (isMemoryFilePath(filePath)) { 1474 paths.add(filePath) 1475 } 1476 } 1477 1478 return Array.from(paths) 1479}