diff --git a/docs/instruction-files.md b/docs/instruction-files.md index c9ea13e418..fbdb3a26cc 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -2,13 +2,17 @@ ## Overview -cmux layers instructions from two locations: +cmux layers instructions from two sources: -1. `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — global defaults -2. `/AGENTS.md` (+ optional `AGENTS.local.md`) — workspace-specific context +1. **Global**: `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — always included +2. **Project**: Either workspace OR project AGENTS.md (not both): + - **Workspace**: `/AGENTS.md` (+ optional `AGENTS.local.md`) — if exists + - **Project**: `/AGENTS.md` (+ optional `AGENTS.local.md`) — fallback if workspace doesn't exist Priority within each location: `AGENTS.md` → `AGENT.md` → `CLAUDE.md` (first match wins). If the base file is found, cmux also appends `AGENTS.local.md` from the same directory when present. +**Fallback behavior**: Workspace instructions **replace** project instructions (not layered). If a workspace doesn't have AGENTS.md, the project root's AGENTS.md is used. This is particularly useful for SSH workspaces where files may not be fully cloned yet. + ## Mode Prompts > Use mode-specific sections to optimize context and customize the behavior specific modes. @@ -19,7 +23,7 @@ cmux reads mode context from sections inside your instruction files. Add a headi Rules: -- Workspace instructions are checked first, then global instructions +- Project instructions (workspace or project fallback) are checked first, then global instructions - The first matching section wins (at most one section is used) - The section's content is everything until the next heading of the same or higher level - Missing sections are ignored (no error) diff --git a/src/services/aiService.ts b/src/services/aiService.ts index 98e94e2e0d..fe15e1ca23 100644 --- a/src/services/aiService.ts +++ b/src/services/aiService.ts @@ -508,6 +508,7 @@ export class AIService extends EventEmitter { // Build system message from workspace metadata const systemMessage = await buildSystemMessage( metadata, + runtime, workspacePath, mode, additionalSystemInstructions diff --git a/src/services/systemMessage.test.ts b/src/services/systemMessage.test.ts index 40e50589cc..23554c0580 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -5,24 +5,32 @@ import { buildSystemMessage } from "./systemMessage"; import type { WorkspaceMetadata } from "@/types/workspace"; import { spyOn, describe, test, expect, beforeEach, afterEach } from "bun:test"; import type { Mock } from "bun:test"; +import { LocalRuntime } from "@/runtime/LocalRuntime"; describe("buildSystemMessage", () => { let tempDir: string; + let projectDir: string; let workspaceDir: string; let globalDir: string; let mockHomedir: Mock; + let runtime: LocalRuntime; beforeEach(async () => { // Create temp directory for test tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "systemMessage-test-")); + projectDir = path.join(tempDir, "project"); workspaceDir = path.join(tempDir, "workspace"); globalDir = path.join(tempDir, ".cmux"); + await fs.mkdir(projectDir, { recursive: true }); await fs.mkdir(workspaceDir, { recursive: true }); await fs.mkdir(globalDir, { recursive: true }); // Mock homedir to return our test directory (getSystemDirectory will append .cmux) mockHomedir = spyOn(os, "homedir"); mockHomedir.mockReturnValue(tempDir); + + // Create a local runtime for tests + runtime = new LocalRuntime(tempDir); }); afterEach(async () => { @@ -33,9 +41,9 @@ describe("buildSystemMessage", () => { }); test("includes mode-specific section when mode is provided", async () => { - // Write instruction file with mode section + // Write instruction file with mode section to projectDir await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), + path.join(projectDir, "AGENTS.md"), `# General Instructions Always be helpful. @@ -49,10 +57,10 @@ Use diagrams where appropriate. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); + const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan"); // Should include the mode-specific content expect(systemMessage).toContain(""); @@ -65,9 +73,9 @@ Use diagrams where appropriate. }); test("excludes mode-specific section when mode is not provided", async () => { - // Write instruction file with mode section + // Write instruction file with mode section to projectDir await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), + path.join(projectDir, "AGENTS.md"), `# General Instructions Always be helpful. @@ -80,10 +88,10 @@ Focus on planning and design. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir); + const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir); // Should NOT include the mode-specific tag expect(systemMessage).not.toContain(""); @@ -94,7 +102,7 @@ Focus on planning and design. expect(systemMessage).toContain("Focus on planning and design"); }); - test("prefers workspace mode section over global mode section", async () => { + test("prefers project mode section over global mode section", async () => { // Write global instruction file with mode section await fs.writeFile( path.join(globalDir, "AGENTS.md"), @@ -105,13 +113,13 @@ Global plan instructions. ` ); - // Write workspace instruction file with mode section + // Write project instruction file with mode section await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), - `# Workspace Instructions + path.join(projectDir, "AGENTS.md"), + `# Project Instructions ## Mode: Plan -Workspace plan instructions (should win). +Project plan instructions (should win). ` ); @@ -119,19 +127,19 @@ Workspace plan instructions (should win). id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); + const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan"); - // Should include workspace mode section in the tag (workspace wins) - expect(systemMessage).toMatch(/\s*Workspace plan instructions \(should win\)\./s); + // Should include project mode section in the tag (project wins) + expect(systemMessage).toMatch(/\s*Project plan instructions \(should win\)\./s); // Global instructions are still present in section (that's correct) - // But the mode-specific section should only have workspace content + // But the mode-specific section should only have project content expect(systemMessage).not.toMatch(/[^<]*Global plan instructions/s); }); - test("falls back to global mode section when workspace has none", async () => { + test("falls back to global mode section when project has none", async () => { // Write global instruction file with mode section await fs.writeFile( path.join(globalDir, "AGENTS.md"), @@ -142,11 +150,11 @@ Global plan instructions. ` ); - // Write workspace instruction file WITHOUT mode section + // Write project instruction file WITHOUT mode section await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), - `# Workspace Instructions -Just general workspace stuff. + path.join(projectDir, "AGENTS.md"), + `# Project Instructions +Just general project stuff. ` ); @@ -154,10 +162,10 @@ Just general workspace stuff. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); + const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan"); // Should include global mode section as fallback expect(systemMessage).toContain("Global plan instructions"); @@ -165,7 +173,7 @@ Just general workspace stuff. test("handles mode with special characters by sanitizing tag name", async () => { await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), + path.join(projectDir, "AGENTS.md"), `## Mode: My-Special_Mode! Special mode instructions. ` @@ -175,10 +183,15 @@ Special mode instructions. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir, "My-Special_Mode!"); + const systemMessage = await buildSystemMessage( + metadata, + runtime, + workspaceDir, + "My-Special_Mode!" + ); // Tag should be sanitized to only contain valid characters expect(systemMessage).toContain(""); diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index a1cff1c9bf..ed39887509 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -1,8 +1,9 @@ import * as os from "os"; import * as path from "path"; import type { WorkspaceMetadata } from "@/types/workspace"; -import { gatherInstructionSets, readInstructionSet } from "@/utils/main/instructionFiles"; +import { readInstructionSet, readInstructionSetFromRuntime } from "@/utils/main/instructionFiles"; import { extractModeSection } from "@/utils/main/markdown"; +import type { Runtime } from "@/runtime/Runtime"; // NOTE: keep this in sync with the docs/models.md file @@ -28,6 +29,9 @@ Use GitHub-style \`
/\` tags to create collapsible sections for `; +/** + * Build environment context XML block describing the workspace. + */ function buildEnvironmentContext(workspacePath: string): string { return ` @@ -42,95 +46,71 @@ You are in a git worktree at ${workspacePath} } /** - * The system directory where global cmux configuration lives. - * This is where users can place global AGENTS.md and .cmux/PLAN.md files - * that apply to all workspaces. + * Get the system directory where global cmux configuration lives. + * Users can place global AGENTS.md and .cmux/PLAN.md files here. */ function getSystemDirectory(): string { return path.join(os.homedir(), ".cmux"); } /** - * Builds a system message for the AI model by combining multiple instruction sources. - * - * Instruction sources are layered in this order: - * 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md) - * 2. Workspace instructions: /AGENTS.md (+ AGENTS.local.md) - * 3. Mode-specific context (if mode provided): Extract a section titled "Mode: " - * (case-insensitive) from the instruction file. We search at most one section in - * precedence order: workspace instructions first, then global instructions. + * Builds a system message for the AI model by combining instruction sources. * - * Each instruction file location is searched for in priority order: - * - AGENTS.md - * - AGENT.md - * - CLAUDE.md + * Instruction layers: + * 1. Global: ~/.cmux/AGENTS.md (always included) + * 2. Context: workspace/AGENTS.md OR project/AGENTS.md (workspace takes precedence) + * 3. Mode: Extracts "Mode: " section from context then global (if mode provided) * - * If a base instruction file is found, its corresponding .local.md variant is also - * checked and appended when building the instruction set (useful for personal preferences not committed to git). + * File search order: AGENTS.md → AGENT.md → CLAUDE.md + * Local variants: AGENTS.local.md appended if found (for .gitignored personal preferences) * - * @param metadata - Workspace metadata - * @param workspacePath - Absolute path to the workspace worktree directory - * @param mode - Optional mode name (e.g., "plan", "exec") - looks for {MODE}.md files if provided - * @param additionalSystemInstructions - Optional additional system instructions to append at the end - * @returns System message string with all instruction sources combined - * @throws Error if metadata is invalid + * @param metadata - Workspace metadata (contains projectPath) + * @param runtime - Runtime for reading workspace files (supports SSH) + * @param workspacePath - Workspace directory path + * @param mode - Optional mode name (e.g., "plan", "exec") + * @param additionalSystemInstructions - Optional instructions appended last + * @throws Error if metadata or workspacePath invalid */ export async function buildSystemMessage( metadata: WorkspaceMetadata, + runtime: Runtime, workspacePath: string, mode?: string, additionalSystemInstructions?: string ): Promise { - // Validate inputs - if (!metadata) { - throw new Error("Invalid workspace metadata: metadata is required"); - } - if (!workspacePath) { - throw new Error("Invalid workspace path: workspacePath is required"); - } + if (!metadata) throw new Error("Invalid workspace metadata: metadata is required"); + if (!workspacePath) throw new Error("Invalid workspace path: workspacePath is required"); - const systemDir = getSystemDirectory(); - const workspaceDir = workspacePath; + // Read instruction sets + const globalInstructions = await readInstructionSet(getSystemDirectory()); + const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); + const contextInstructions = + workspaceInstructions ?? (await readInstructionSet(metadata.projectPath)); - // Gather instruction sets from both global and workspace directories - // Global instructions apply first, then workspace-specific ones - const instructionDirectories = [systemDir, workspaceDir]; - const instructionSegments = await gatherInstructionSets(instructionDirectories); - const customInstructions = instructionSegments.join("\n\n"); + // Combine: global + context (workspace takes precedence over project) + const customInstructions = [globalInstructions, contextInstructions].filter(Boolean).join("\n\n"); - // Look for a "Mode: " section inside instruction sets, preferring workspace over global - // This behavior is documented in docs/instruction-files.md - keep both in sync when changing. + // Extract mode-specific section (context first, then global fallback) let modeContent: string | null = null; if (mode) { - const workspaceInstructions = await readInstructionSet(workspaceDir); - if (workspaceInstructions) { - modeContent = extractModeSection(workspaceInstructions, mode); - } - if (!modeContent) { - const globalInstructions = await readInstructionSet(systemDir); - if (globalInstructions) { - modeContent = extractModeSection(globalInstructions, mode); - } - } + modeContent = + (contextInstructions && extractModeSection(contextInstructions, mode)) ?? + (globalInstructions && extractModeSection(globalInstructions, mode)) ?? + null; } - // Build the final system message - const environmentContext = buildEnvironmentContext(workspaceDir); - const trimmedPrelude = PRELUDE.trim(); - let systemMessage = `${trimmedPrelude}\n\n${environmentContext}`; + // Build system message + let systemMessage = `${PRELUDE.trim()}\n\n${buildEnvironmentContext(workspacePath)}`; - // Add custom instructions if found if (customInstructions) { systemMessage += `\n\n${customInstructions}\n`; } - // Add mode-specific content if found if (modeContent) { const tag = (mode ?? "mode").toLowerCase().replace(/[^a-z0-9_-]/gi, "-"); systemMessage += `\n\n<${tag}>\n${modeContent}\n`; } - // Add additional system instructions at the end (highest priority) if (additionalSystemInstructions) { systemMessage += `\n\n\n${additionalSystemInstructions}\n`; } diff --git a/src/utils/main/instructionFiles.test.ts b/src/utils/main/instructionFiles.test.ts index f3d7e227cd..9326cf4630 100644 --- a/src/utils/main/instructionFiles.test.ts +++ b/src/utils/main/instructionFiles.test.ts @@ -1,12 +1,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; -import { - readFirstAvailableFile, - readInstructionSet, - gatherInstructionSets, - INSTRUCTION_FILE_NAMES, -} from "./instructionFiles"; +import { readInstructionSet, gatherInstructionSets } from "./instructionFiles"; describe("instructionFiles", () => { let tempDir: string; @@ -19,36 +14,6 @@ describe("instructionFiles", () => { await fs.rm(tempDir, { recursive: true, force: true }); }); - describe("readFirstAvailableFile", () => { - it("should return null when no files exist", async () => { - const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES); - expect(result).toBeNull(); - }); - - it("should return content of first available file in priority order", async () => { - await fs.writeFile(path.join(tempDir, "AGENT.md"), "agent content"); - await fs.writeFile(path.join(tempDir, "CLAUDE.md"), "claude content"); - - const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES); - expect(result).toBe("agent content"); - }); - - it("should prefer AGENTS.md over AGENT.md", async () => { - await fs.writeFile(path.join(tempDir, "AGENTS.md"), "agents content"); - await fs.writeFile(path.join(tempDir, "AGENT.md"), "agent content"); - - const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES); - expect(result).toBe("agents content"); - }); - - it("should fall back to lower priority files", async () => { - await fs.writeFile(path.join(tempDir, "CLAUDE.md"), "claude content"); - - const result = await readFirstAvailableFile(tempDir, INSTRUCTION_FILE_NAMES); - expect(result).toBe("claude content"); - }); - }); - describe("readInstructionSet", () => { it("should return null when no instruction files exist", async () => { const result = await readInstructionSet(tempDir); diff --git a/src/utils/main/instructionFiles.ts b/src/utils/main/instructionFiles.ts index e16136ba87..c5313938a6 100644 --- a/src/utils/main/instructionFiles.ts +++ b/src/utils/main/instructionFiles.ts @@ -1,5 +1,7 @@ import * as fs from "fs/promises"; import * as path from "path"; +import type { Runtime } from "@/runtime/Runtime"; +import { readFileString } from "@/utils/runtime/helpers"; /** * Instruction file names to search for, in priority order. @@ -13,81 +15,88 @@ export const INSTRUCTION_FILE_NAMES = ["AGENTS.md", "AGENT.md", "CLAUDE.md"] as * * Example: If AGENTS.md exists, we also check for AGENTS.local.md */ -const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; +export const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; /** - * Attempts to read the first available file from a list of filenames in a directory. + * File reader abstraction for reading files from either local fs or Runtime. + */ +interface FileReader { + readFile(filePath: string): Promise; +} + +/** + * Create a FileReader for local filesystem access. + */ +function createLocalFileReader(): FileReader { + return { + readFile: (filePath: string) => fs.readFile(filePath, "utf-8"), + }; +} + +/** + * Create a FileReader for Runtime-based access (supports SSH). + */ +function createRuntimeFileReader(runtime: Runtime): FileReader { + return { + readFile: (filePath: string) => readFileString(runtime, filePath), + }; +} + +/** + * Read the first available file from a list using the provided file reader. * + * @param reader - FileReader abstraction (local or runtime) * @param directory - Directory to search in * @param filenames - List of filenames to try, in priority order * @returns Content of the first file found, or null if none exist */ -export async function readFirstAvailableFile( +async function readFirstAvailableFile( + reader: FileReader, directory: string, filenames: readonly string[] ): Promise { for (const filename of filenames) { try { - const filePath = path.join(directory, filename); - const content = await fs.readFile(filePath, "utf-8"); - return content; + return await reader.readFile(path.join(directory, filename)); } catch { - // File doesn't exist or can't be read, try next - continue; + continue; // File doesn't exist, try next } } return null; } /** - * Attempts to read a local variant of an instruction file. - * - * Local files allow users to keep personal preferences separate from - * shared team instructions (e.g., add AGENTS.local.md to .gitignore). - */ - -/** - * Reads a base file with an optional local variant and returns their combined content. + * Read a base file with optional local variant using the provided file reader. * - * @param directory - Directory to search (can be null/undefined) + * @param reader - FileReader abstraction (local or runtime) + * @param directory - Directory to search * @param baseFilenames - Base filenames to try in priority order * @param localFilename - Optional local filename to append if present * @returns Combined content or null if no base file exists */ -export async function readFileWithLocalVariant( - directory: string | null | undefined, +async function readFileWithLocalVariant( + reader: FileReader, + directory: string, baseFilenames: readonly string[], localFilename?: string ): Promise { - if (!directory) { - return null; - } - - const normalizedDirectory = path.resolve(directory); - const baseContent = await readFirstAvailableFile(normalizedDirectory, baseFilenames); - - if (!baseContent) { - return null; - } - - if (!localFilename) { - return baseContent; - } + const baseContent = await readFirstAvailableFile(reader, directory, baseFilenames); + if (!baseContent) return null; + if (!localFilename) return baseContent; try { - const localFilePath = path.join(normalizedDirectory, localFilename); - const localContent = await fs.readFile(localFilePath, "utf-8"); + const localContent = await reader.readFile(path.join(directory, localFilename)); return `${baseContent}\n\n${localContent}`; } catch { - return baseContent; + return baseContent; // Local variant missing, return base only } } /** - * Reads an instruction set from a directory. + * Read an instruction set from a local directory. * * An instruction set consists of: - * 1. A base instruction file (first found from INSTRUCTION_FILE_NAMES) + * 1. A base instruction file (AGENTS.md → AGENT.md → CLAUDE.md, first found wins) * 2. An optional local instruction file (AGENTS.local.md) * * If both exist, they are concatenated with a blank line separator. @@ -95,8 +104,38 @@ export async function readFileWithLocalVariant( * @param directory - Directory to search for instruction files * @returns Combined instruction content, or null if no base file exists */ -export async function readInstructionSet(directory: string): Promise { - return readFileWithLocalVariant(directory, INSTRUCTION_FILE_NAMES, LOCAL_INSTRUCTION_FILENAME); +export async function readInstructionSet( + directory: string | null | undefined +): Promise { + if (!directory) return null; + const reader = createLocalFileReader(); + return readFileWithLocalVariant( + reader, + path.resolve(directory), + INSTRUCTION_FILE_NAMES, + LOCAL_INSTRUCTION_FILENAME + ); +} + +/** + * Read an instruction set from a workspace using Runtime abstraction. + * Supports both local and remote (SSH) workspaces. + * + * @param runtime - Runtime instance (may be local or SSH) + * @param directory - Directory to search for instruction files + * @returns Combined instruction content, or null if no base file exists + */ +export async function readInstructionSetFromRuntime( + runtime: Runtime, + directory: string +): Promise { + const reader = createRuntimeFileReader(runtime); + return readFileWithLocalVariant( + reader, + directory, + INSTRUCTION_FILE_NAMES, + LOCAL_INSTRUCTION_FILENAME + ); } /** diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index f852fbb288..e318f9b123 100644 --- a/tests/ipcMain/sendMessage.test.ts +++ b/tests/ipcMain/sendMessage.test.ts @@ -700,10 +700,11 @@ describeIntegration("IpcMain sendMessage integration tests", () => { "should include mode-specific instructions in system message", async () => { // Setup test environment - const { env, workspaceId, workspacePath, cleanup } = await setupWorkspace(provider); + const { env, workspaceId, tempGitRepo, cleanup } = await setupWorkspace(provider); try { // Write AGENTS.md with mode-specific sections containing distinctive markers - const agentsMdPath = path.join(workspacePath, "AGENTS.md"); + // Note: AGENTS.md is read from project root, not workspace directory + const agentsMdPath = path.join(tempGitRepo, "AGENTS.md"); const agentsMdContent = `# Instructions ## General Instructions