/ utils / claudemd.ts
claudemd.ts
   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  
  28  import { feature } from 'bun:bundle'
  29  import ignore from 'ignore'
  30  import memoize from 'lodash-es/memoize.js'
  31  import { Lexer } from 'marked'
  32  import {
  33    basename,
  34    dirname,
  35    extname,
  36    isAbsolute,
  37    join,
  38    parse,
  39    relative,
  40    sep,
  41  } from 'path'
  42  import picomatch from 'picomatch'
  43  import { logEvent } from 'src/services/analytics/index.js'
  44  import {
  45    getAdditionalDirectoriesForClaudeMd,
  46    getOriginalCwd,
  47  } from '../bootstrap/state.js'
  48  import { truncateEntrypointContent } from '../memdir/memdir.js'
  49  import { getAutoMemEntrypoint, isAutoMemoryEnabled } from '../memdir/paths.js'
  50  import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
  51  import {
  52    getCurrentProjectConfig,
  53    getManagedClaudeRulesDir,
  54    getMemoryPath,
  55    getUserClaudeRulesDir,
  56  } from './config.js'
  57  import { logForDebugging } from './debug.js'
  58  import { logForDiagnosticsNoPII } from './diagLogs.js'
  59  import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js'
  60  import { getErrnoCode } from './errors.js'
  61  import { normalizePathForComparison } from './file.js'
  62  import { cacheKeys, type FileStateCache } from './fileStateCache.js'
  63  import {
  64    parseFrontmatter,
  65    splitPathInFrontmatter,
  66  } from './frontmatterParser.js'
  67  import { getFsImplementation, safeResolvePath } from './fsOperations.js'
  68  import { findCanonicalGitRoot, findGitRoot } from './git.js'
  69  import {
  70    executeInstructionsLoadedHooks,
  71    hasInstructionsLoadedHook,
  72    type InstructionsLoadReason,
  73    type InstructionsMemoryType,
  74  } from './hooks.js'
  75  import type { MemoryType } from './memory/types.js'
  76  import { expandPath } from './path.js'
  77  import { pathInWorkingPath } from './permissions/filesystem.js'
  78  import { isSettingSourceEnabled } from './settings/constants.js'
  79  import { getInitialSettings } from './settings/settings.js'
  80  
  81  /* eslint-disable @typescript-eslint/no-require-imports */
  82  const 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  
  87  let hasLoggedInitialLoad = false
  88  
  89  const 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
  92  export 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
  96  const 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  
 229  export 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  
 245  function 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   */
 254  function 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   */
 292  export 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  
 303  function 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   */
 343  function 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  
 402  function 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   */
 424  async 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  
 439  type 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.
 451  function 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  
 537  const 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   */
 547  function 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   */
 581  function 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   */
 618  export 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   */
 697  export 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  
 790  export 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  
1077  function 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.
1093  let 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.
1100  let shouldFireHook = true
1101  
1102  function 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   */
1119  export function clearMemoryFileCaches(): void {
1120    // ?.cache because tests spyOn this, which replaces the memoize wrapper.
1121    getMemoryFiles.cache?.clear?.()
1122  }
1123  
1124  export function resetGetMemoryFilesCache(
1125    reason: InstructionsLoadReason = 'session_start',
1126  ): void {
1127    nextEagerLoadReason = reason
1128    shouldFireHook = true
1129    clearMemoryFileCaches()
1130  }
1131  
1132  export 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   */
1142  export 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  
1153  export 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   */
1205  export 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   */
1249  export 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   */
1329  export 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   */
1354  export 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  
1399  export type ExternalClaudeMdInclude = {
1400    path: string
1401    parent: string
1402  }
1403  
1404  export 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  
1416  export function hasExternalClaudeMdIncludes(files: MemoryFileInfo[]): boolean {
1417    return getExternalClaudeMdIncludes(files).length > 0
1418  }
1419  
1420  export 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   */
1435  export 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   */
1460  export 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  }