feat: add support for AGENTS.local.md personal override files (#11183)

Co-authored-by: Roo Code <roomote@roocode.com>
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
This commit is contained in:
roomote[bot] 2026-02-04 12:53:13 -08:00 • committed by GitHub
parent 7f6272afb9
commit 1da2b1c457
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 197 additions and 29 deletions

1
.gitignore vendored
View file

@ -18,6 +18,7 @@ bin/
# Local prompts and rules
/local-prompts
AGENTS.local.md
# Test environment
.test_env

View file

@ -1587,4 +1587,136 @@ describe("Rules directory reading", () => {
const result = await loadRuleFiles("/fake/path")
expect(result).toBe("\n# Rules from .roorules:\nfallback content\n")
})
it("should load AGENTS.local.md alongside AGENTS.md for personal overrides", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate both AGENTS.md and AGENTS.local.md exist (not symlinks)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md") || pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve("Local overrides from AGENTS.local.md")
}
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Base rules from AGENTS.md")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)
// Should contain both AGENTS.md and AGENTS.local.md content
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Base rules from AGENTS.md")
expect(result).toContain("# Agent Rules Local (AGENTS.local.md):")
expect(result).toContain("Local overrides from AGENTS.local.md")
})
it("should load AGENTS.local.md even when base AGENTS.md does not exist", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate only AGENTS.local.md exists (no base file)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.local.md")) {
return Promise.resolve("Local overrides without base file")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)
// Should contain AGENTS.local.md content even without base AGENTS.md
expect(result).toContain("# Agent Rules Local (AGENTS.local.md):")
expect(result).toContain("Local overrides without base file")
})
it("should load AGENTS.md without .local.md when local file does not exist", async () => {
// Simulate no .roo/rules-test-mode directory
statMock.mockRejectedValueOnce({ code: "ENOENT" })
// Mock lstat to indicate only AGENTS.md exists (no local override)
lstatMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve({
isSymbolicLink: vi.fn().mockReturnValue(false),
})
}
return Promise.reject({ code: "ENOENT" })
})
readFileMock.mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString()
if (pathStr.endsWith("AGENTS.md")) {
return Promise.resolve("Base rules from AGENTS.md only")
}
return Promise.reject({ code: "ENOENT" })
})
const result = await addCustomInstructions(
"mode instructions",
"global instructions",
"/fake/path",
"test-mode",
{
settings: {
todoListEnabled: true,
useAgentRules: true,
newTaskRequireTodos: false,
},
},
)
// Should contain only AGENTS.md content
expect(result).toContain("# Agent Rules Standard (AGENTS.md):")
expect(result).toContain("Base rules from AGENTS.md only")
expect(result).not.toContain("AGENTS.local.md")
})
})

View file

@ -238,9 +238,48 @@ export async function loadRuleFiles(cwd: string, enableSubfolderRules: boolean =
return ""
}
/**
* Read content from an agent rules file (AGENTS.md, AGENT.md, etc.)
* Handles symlink resolution.
*
* @param filePath - Full path to the agent rules file
* @returns File content or empty string if file doesn't exist
*/
async function readAgentRulesFile(filePath: string): Promise<string> {
let resolvedPath = filePath
// Check if file exists and handle symlinks
try {
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{
originalPath: string
resolvedPath: string
}> = []
// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(filePath, fileInfo, 0)
// Extract the resolved path from fileInfo
if (fileInfo.length > 0) {
resolvedPath = fileInfo[0].resolvedPath
}
}
} catch (err) {
// If lstat fails (file doesn't exist), return empty
return ""
}
// Read the content from the resolved path
return safeReadFile(resolvedPath)
}
/**
* Load AGENTS.md or AGENT.md file from a specific directory
* Checks for both AGENTS.md (standard) and AGENT.md (alternative) for compatibility
* Also loads AGENTS.local.md for personal overrides (not checked in to version control)
* AGENTS.local.md can be loaded even if AGENTS.md doesn't exist
*
* @param directory - Directory to check for AGENTS.md
* @param showPath - Whether to include the directory path in the header
@ -253,50 +292,46 @@ async function loadAgentRulesFileFromDirectory(
): Promise<string> {
// Try both filenames - AGENTS.md (standard) first, then AGENT.md (alternative)
const filenames = ["AGENTS.md", "AGENT.md"]
const results: string[] = []
const displayPath = cwd ? path.relative(cwd, directory) : directory
for (const filename of filenames) {
try {
const agentPath = path.join(directory, filename)
let resolvedPath = agentPath
const content = await readAgentRulesFile(agentPath)
// Check if file exists and handle symlinks
try {
const stats = await fs.lstat(agentPath)
if (stats.isSymbolicLink()) {
// Create a temporary fileInfo array to use with resolveSymLink
const fileInfo: Array<{
originalPath: string
resolvedPath: string
}> = []
// Use the existing resolveSymLink function to handle symlink resolution
await resolveSymLink(agentPath, fileInfo, 0)
// Extract the resolved path from fileInfo
if (fileInfo.length > 0) {
resolvedPath = fileInfo[0].resolvedPath
}
}
} catch (err) {
// If lstat fails (file doesn't exist), try next filename
continue
}
// Read the content from the resolved path
const content = await safeReadFile(resolvedPath)
if (content) {
// Compute relative path for display if cwd is provided
const displayPath = cwd ? path.relative(cwd, directory) : directory
const header = showPath
? `# Agent Rules Standard (${filename}) from ${displayPath}:`
: `# Agent Rules Standard (${filename}):`
return `${header}\n${content}`
results.push(`${header}\n${content}`)
// Found a standard file, don't check alternative
break
}
} catch (err) {
// Silently ignore errors - agent rules files are optional
}
}
return ""
// Always try to load AGENTS.local.md for personal overrides (even if AGENTS.md doesn't exist)
try {
const localFilename = "AGENTS.local.md"
const localPath = path.join(directory, localFilename)
const localContent = await readAgentRulesFile(localPath)
if (localContent) {
const localHeader = showPath
? `# Agent Rules Local (${localFilename}) from ${displayPath}:`
: `# Agent Rules Local (${localFilename}):`
results.push(`${localHeader}\n${localContent}`)
}
} catch (err) {
// Silently ignore errors - local agent rules file is optional
}
return results.join("\n\n")
}
/**