Browse Source

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

Co-authored-by: Roo Code <[email protected]>
Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com>
roomote[bot] 1 week ago
parent
commit
1da2b1c457

+ 1 - 0
.gitignore

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

+ 132 - 0
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")
+	})
 })

+ 64 - 29
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<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
-
-			// 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")
 }
 
 /**