Prefer AGENTS.md over CLAUDE.md for project instructions (#439)

* Prefer AGENTS.md over CLAUDE.md for project instructions

* fix: preserve CLAUDE.md fallback behavior

* fix: isolate onboarding tests and preserve legacy init

* fix: restore full fsOperations exports in test mock and align compact cwd

* Fix onboarding test isolation and init migration guidance

* Tighten init prompt coverage and onboarding copy

* Handle nested project instruction paths consistently

* Fix NEW_INIT feature gate for Bun build

---------

Co-authored-by: 赵小落 <zhaoxiaoluo@zhaoxiaoluodeMac-mini.local>
Co-authored-by: zhaomo01 <zhaomo01@baidu.com>
This commit is contained in:
ZhaoXiaoLuo
2026-04-12 21:31:33 +08:00
committed by GitHub
parent 2e0e14d713
commit b3f3dc4e66
18 changed files with 521 additions and 105 deletions

View File

@@ -3,7 +3,7 @@
*
* 1. Managed memory (eg. /etc/claude-code/CLAUDE.md) - Global instructions for all users
* 2. User memory (~/.claude/CLAUDE.md) - Private global instructions for all projects
* 3. Project memory (CLAUDE.md, .claude/CLAUDE.md, and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 3. Project memory (AGENTS.md or fallback CLAUDE.md, plus .claude/CLAUDE.md and .claude/rules/*.md in project roots) - Instructions checked into the codebase
* 4. Local memory (CLAUDE.local.md in project roots) - Private project-specific instructions
*
* Files are loaded in reverse order of priority, i.e. the latest files are highest priority
@@ -13,7 +13,8 @@
* - User memory is loaded from the user's home directory
* - Project and Local files are discovered by traversing from the current directory up to root
* - Files closer to the current directory have higher priority (loaded later)
* - CLAUDE.md, .claude/CLAUDE.md, and all .md files in .claude/rules/ are checked in each directory for Project memory
* - AGENTS.md is preferred for root project instructions; CLAUDE.md is only used when AGENTS.md is absent
* - .claude/CLAUDE.md and all .md files in .claude/rules/ are checked in each directory for Project memory
*
* Memory @include directive:
* - Memory files can include other files using @ notation
@@ -75,6 +76,10 @@ import {
import type { MemoryType } from './memory/types.js'
import { expandPath } from './path.js'
import { pathInWorkingPath } from './permissions/filesystem.js'
import {
getProjectInstructionFilePath,
isProjectInstructionFileName,
} from './projectInstructions.js'
import { isSettingSourceEnabled } from './settings/constants.js'
import { getInitialSettings } from './settings/settings.js'
@@ -868,7 +873,7 @@ export const getMemoryFiles = memoize(
// When running from a git worktree nested inside its main repo (e.g.,
// .claude/worktrees/<name>/ from `claude -w`), the upward walk passes
// through both the worktree root and the main repo root. Both contain
// checked-in files like CLAUDE.md and .claude/rules/*.md, so the same
// checked-in files like AGENTS.md/CLAUDE.md and .claude/rules/*.md, so the same
// content gets loaded twice. Skip Project-type (checked-in) files from
// directories above the worktree but within the main repo — the worktree
// already has its own checkout. CLAUDE.local.md is gitignored so it only
@@ -892,9 +897,12 @@ export const getMemoryFiles = memoize(
pathInWorkingPath(dir, canonicalRoot) &&
!pathInWorkingPath(dir, gitRoot)
// Try reading CLAUDE.md (Project) - only if projectSettings is enabled
// Try reading the root project instruction file (AGENTS.md first, otherwise CLAUDE.md)
if (isSettingSourceEnabled('projectSettings') && !skipProject) {
const projectPath = join(dir, 'CLAUDE.md')
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -942,15 +950,18 @@ export const getMemoryFiles = memoize(
}
}
// Process CLAUDE.md from additional directories (--add-dir) if env var is enabled
// Process root project instruction files from additional directories (--add-dir) if env var is enabled
// This is controlled by CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD and defaults to off
// Note: we don't check isSettingSourceEnabled('projectSettings') here because --add-dir
// is an explicit user action and the SDK defaults settingSources to [] when not specified
if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) {
const additionalDirs = getAdditionalDirectoriesForClaudeMd()
for (const dir of additionalDirs) {
// Try reading CLAUDE.md from the additional directory
const projectPath = join(dir, 'CLAUDE.md')
// Try reading the root project instruction file from the additional directory
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -1248,7 +1259,7 @@ export async function getManagedAndUserConditionalRules(
/**
* Gets memory files for a single nested directory (between CWD and target).
* Loads CLAUDE.md, unconditional rules, and conditional rules for that directory.
* Loads the root project instruction file, unconditional rules, and conditional rules for that directory.
*
* @param dir The directory to process
* @param targetPath The target file path (for conditional rule matching)
@@ -1262,9 +1273,12 @@ export async function getMemoryFilesForNestedDirectory(
): Promise<MemoryFileInfo[]> {
const result: MemoryFileInfo[] = []
// Process project memory files (CLAUDE.md and .claude/CLAUDE.md)
// Process project memory files (AGENTS.md first, otherwise CLAUDE.md, plus .claude/CLAUDE.md)
if (isSettingSourceEnabled('projectSettings')) {
const projectPath = join(dir, 'CLAUDE.md')
const projectPath = getProjectInstructionFilePath(
dir,
getFsImplementation().existsSync,
)
result.push(
...(await processMemoryFile(
projectPath,
@@ -1439,13 +1453,13 @@ export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boole
}
/**
* Check if a file path is a memory file (CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
* Check if a file path is a memory file (AGENTS.md, CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md)
*/
export function isMemoryFilePath(filePath: string): boolean {
const name = basename(filePath)
// CLAUDE.md or CLAUDE.local.md anywhere
if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') {
// Root instruction files or CLAUDE.local.md anywhere
if (isProjectInstructionFileName(name) || name === 'CLAUDE.local.md') {
return true
}

View File

@@ -31,6 +31,7 @@ import { normalizePathForConfigKey } from './path.js'
import { getEssentialTrafficOnlyReason } from './privacyLevel.js'
import { getManagedFilePath } from './settings/managedPath.js'
import type { ThemeSetting } from './theme.js'
import { PRIMARY_PROJECT_INSTRUCTION_FILE } from './projectInstructions.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const teamMemPaths = feature('TEAMMEM')
@@ -1823,7 +1824,7 @@ export function getMemoryPath(memoryType: MemoryType): string {
case 'Local':
return join(cwd, 'CLAUDE.local.md')
case 'Project':
return join(cwd, 'CLAUDE.md')
return join(cwd, PRIMARY_PROJECT_INSTRUCTION_FILE)
case 'Managed':
return join(getManagedFilePath(), 'CLAUDE.md')
case 'AutoMem':

View File

@@ -0,0 +1,105 @@
import { describe, expect, test } from 'bun:test'
import { join } from 'node:path'
import {
findProjectInstructionFilePathInAncestors,
FALLBACK_PROJECT_INSTRUCTION_FILE,
getProjectInstructionFilePath,
getProjectInstructionFilePaths,
hasProjectInstructionFile,
isProjectInstructionFileName,
PRIMARY_PROJECT_INSTRUCTION_FILE,
} from './projectInstructions.js'
describe('projectInstructions', () => {
test('prefers AGENTS.md over CLAUDE.md for root project instructions', () => {
const dir = '/repo'
const existingPaths = new Set([
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
const filePath = getProjectInstructionFilePath(
dir,
path => existingPaths.has(path),
)
expect(filePath).toBe(join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE))
})
test('falls back to CLAUDE.md when AGENTS.md is absent', () => {
const dir = '/repo'
const existingPaths = new Set([join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE)])
const filePath = getProjectInstructionFilePath(
dir,
path => existingPaths.has(path),
)
expect(filePath).toBe(join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE))
})
test('returns both candidate root instruction paths', () => {
const dir = '/repo'
expect(getProjectInstructionFilePaths(dir)).toEqual([
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
})
test('detects whether a repo instruction file exists', () => {
const dir = '/repo'
const existingPaths = new Set([join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
expect(hasProjectInstructionFile(dir, path => existingPaths.has(path))).toBe(
true,
)
expect(hasProjectInstructionFile(dir, () => false)).toBe(false)
})
test('recognizes AGENTS.md as a root instruction filename', () => {
expect(isProjectInstructionFileName(PRIMARY_PROJECT_INSTRUCTION_FILE)).toBe(
true,
)
expect(isProjectInstructionFileName(FALLBACK_PROJECT_INSTRUCTION_FILE)).toBe(
true,
)
expect(isProjectInstructionFileName('README.md')).toBe(false)
})
test('finds repo instructions in ancestor directories', () => {
const repoDir = '/repo'
const nestedDir = join(repoDir, 'packages', 'app')
const existingPaths = new Set([join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE)])
expect(
findProjectInstructionFilePathInAncestors(
nestedDir,
path => existingPaths.has(path),
),
).toBe(join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE))
})
test('prefers the closest ancestor project instruction file', () => {
const repoDir = '/repo'
const nestedProjectDir = join(repoDir, 'packages', 'app')
const existingPaths = new Set([
join(repoDir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE),
])
expect(
findProjectInstructionFilePathInAncestors(
join(nestedProjectDir, 'src'),
path => existingPaths.has(path),
),
).toBe(join(nestedProjectDir, FALLBACK_PROJECT_INSTRUCTION_FILE))
})
test('returns null when no ancestor repo instruction file exists', () => {
expect(
findProjectInstructionFilePathInAncestors('/repo/packages/app', () => false),
).toBeNull()
})
})

View File

@@ -0,0 +1,55 @@
import { dirname, join } from 'path'
export const PRIMARY_PROJECT_INSTRUCTION_FILE = 'AGENTS.md'
export const FALLBACK_PROJECT_INSTRUCTION_FILE = 'CLAUDE.md'
export function getProjectInstructionFilePaths(dir: string): string[] {
return [
join(dir, PRIMARY_PROJECT_INSTRUCTION_FILE),
join(dir, FALLBACK_PROJECT_INSTRUCTION_FILE),
]
}
export function getProjectInstructionFilePath(
dir: string,
existsSync: (path: string) => boolean,
): string {
const [primaryPath, fallbackPath] = getProjectInstructionFilePaths(dir)
return existsSync(primaryPath)
? primaryPath
: fallbackPath
}
export function hasProjectInstructionFile(
dir: string,
existsSync: (path: string) => boolean,
): boolean {
return getProjectInstructionFilePaths(dir).some(path => existsSync(path))
}
export function findProjectInstructionFilePathInAncestors(
startDir: string,
existsSync: (path: string) => boolean,
): string | null {
let currentDir = startDir
while (true) {
if (hasProjectInstructionFile(currentDir, existsSync)) {
return getProjectInstructionFilePath(currentDir, existsSync)
}
const parentDir = dirname(currentDir)
if (parentDir === currentDir) {
return null
}
currentDir = parentDir
}
}
export function isProjectInstructionFileName(name: string): boolean {
return (
name === PRIMARY_PROJECT_INSTRUCTION_FILE ||
name === FALLBACK_PROJECT_INSTRUCTION_FILE
)
}

View File

@@ -1082,10 +1082,10 @@ export const SettingsSchema = lazySchema(() =>
.array(z.string())
.optional()
.describe(
'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' +
'Glob patterns or absolute paths of AGENTS.md/CLAUDE.md files to exclude from loading. ' +
'Patterns are matched against absolute file paths using picomatch. ' +
'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' +
'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
'Examples: "/home/user/monorepo/AGENTS.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"',
),
pluginTrustMessage: z
.string()