diff --git a/.gitignore b/.gitignore index 364b391a012..1dbcdc6a362 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ bin/ # Local prompts and rules /local-prompts +AGENTS.local.md # Test environment .test_env diff --git a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts index ea3b31ee938..68fa2d37f5a 100644 --- a/src/core/prompts/sections/__tests__/custom-instructions.spec.ts +++ b/src/core/prompts/sections/__tests__/custom-instructions.spec.ts @@ -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") + }) }) diff --git a/src/core/prompts/sections/custom-instructions.ts b/src/core/prompts/sections/custom-instructions.ts index 8eee0a09986..46cf1bf1f9e 100644 --- a/src/core/prompts/sections/custom-instructions.ts +++ b/src/core/prompts/sections/custom-instructions.ts @@ -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 { + 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 { // 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 - - // 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 - } + const content = await readAgentRulesFile(agentPath) - // 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") } /**