From 09772a09dde303b6f612c5c5e415207ea49dee0a Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 20:38:02 +0000 Subject: [PATCH 1/9] Change AGENTS.md to read from project root, not workspace Design decision: Read AGENTS.md from projectPath (project root) instead of workspacePath (individual workspace/worktree directories). Rationale: - Ensures consistent instructions across all workspaces for a project - Simplifies mental model: one AGENTS.md per project, not per branch - Aligns with typical workflow where instructions are project-level context - Still allows workspace-specific overrides via AGENTS.local.md (gitignored) Changes: - buildSystemMessage() now reads AGENTS.md from metadata.projectPath - Environment context still uses workspacePath (where code executes) - Updated docs/instruction-files.md to reflect project-level instructions - Updated all tests to use projectDir instead of workspaceDir - Renamed test variables for clarity Breaking change: Branch-specific AGENTS.md files will no longer be read. Migration: Move workspace-specific instructions to project root AGENTS.md, or use AGENTS.local.md for personal/workspace-specific overrides. --- docs/instruction-files.md | 6 ++-- src/services/systemMessage.test.ts | 49 ++++++++++++++++-------------- src/services/systemMessage.ts | 28 +++++++++-------- 3 files changed, 45 insertions(+), 38 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index c9ea13e41..e213e3230 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -5,10 +5,12 @@ cmux layers instructions from two locations: 1. `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — global defaults -2. `/AGENTS.md` (+ optional `AGENTS.local.md`) — workspace-specific context +2. `/AGENTS.md` (+ optional `AGENTS.local.md`) — project-specific context 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. +**Note**: Instructions are read from the project root (where the main repository is located), not from individual workspace directories. This ensures consistent instructions across all workspaces for a project. + ## Mode Prompts > Use mode-specific sections to optimize context and customize the behavior specific modes. @@ -19,7 +21,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 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/systemMessage.test.ts b/src/services/systemMessage.test.ts index 40e50589c..c5854fc5b 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -8,6 +8,7 @@ import type { Mock } from "bun:test"; describe("buildSystemMessage", () => { let tempDir: string; + let projectDir: string; let workspaceDir: string; let globalDir: string; let mockHomedir: Mock; @@ -15,8 +16,10 @@ describe("buildSystemMessage", () => { 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 }); @@ -33,9 +36,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,7 +52,7 @@ Use diagrams where appropriate. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); @@ -65,9 +68,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,7 +83,7 @@ Focus on planning and design. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; const systemMessage = await buildSystemMessage(metadata, workspaceDir); @@ -94,7 +97,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 +108,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 +122,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"); - // 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 +145,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,7 +157,7 @@ Just general workspace stuff. id: "test-workspace", name: "test-workspace", projectName: "test-project", - projectPath: tempDir, + projectPath: projectDir, }; const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); @@ -165,7 +168,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,7 +178,7 @@ 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!"); diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index a1cff1c9b..5cc494fe1 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -55,10 +55,10 @@ function getSystemDirectory(): string { * * Instruction sources are layered in this order: * 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md) - * 2. Workspace instructions: /AGENTS.md (+ AGENTS.local.md) + * 2. Project 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. + * precedence order: project instructions first, then global instructions. * * Each instruction file location is searched for in priority order: * - AGENTS.md @@ -68,8 +68,8 @@ function getSystemDirectory(): string { * 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). * - * @param metadata - Workspace metadata - * @param workspacePath - Absolute path to the workspace worktree directory + * @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md) + * @param workspacePath - Absolute path to the workspace directory (for environment context) * @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 @@ -90,21 +90,22 @@ export async function buildSystemMessage( } const systemDir = getSystemDirectory(); - const workspaceDir = workspacePath; + const projectDir = metadata.projectPath; - // Gather instruction sets from both global and workspace directories - // Global instructions apply first, then workspace-specific ones - const instructionDirectories = [systemDir, workspaceDir]; + // Gather instruction sets from both global and project directories + // Global instructions apply first, then project-specific ones + // Note: We read from projectPath (the main repo) not workspacePath (the worktree) + const instructionDirectories = [systemDir, projectDir]; const instructionSegments = await gatherInstructionSets(instructionDirectories); const customInstructions = instructionSegments.join("\n\n"); - // Look for a "Mode: " section inside instruction sets, preferring workspace over global + // Look for a "Mode: " section inside instruction sets, preferring project over global // This behavior is documented in docs/instruction-files.md - keep both in sync when changing. let modeContent: string | null = null; if (mode) { - const workspaceInstructions = await readInstructionSet(workspaceDir); - if (workspaceInstructions) { - modeContent = extractModeSection(workspaceInstructions, mode); + const projectInstructions = await readInstructionSet(projectDir); + if (projectInstructions) { + modeContent = extractModeSection(projectInstructions, mode); } if (!modeContent) { const globalInstructions = await readInstructionSet(systemDir); @@ -115,7 +116,8 @@ export async function buildSystemMessage( } // Build the final system message - const environmentContext = buildEnvironmentContext(workspaceDir); + // Use workspacePath for environment context (where code actually executes) + const environmentContext = buildEnvironmentContext(workspacePath); const trimmedPrelude = PRELUDE.trim(); let systemMessage = `${trimmedPrelude}\n\n${environmentContext}`; From e8b0b9cff279575c556f9950960fb92f3ca97f19 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 20:55:29 +0000 Subject: [PATCH 2/9] Fix AGENTS.md loading: workspace first, project fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Runtime abstraction support for reading AGENTS.md from workspaces, with graceful fallback to project root when workspace file doesn't exist. Problem: - SSH workspaces are on remote machines, can't use local fs.readFile() - AGENTS.md may not be cloned into SSH workspace yet - Need to support both local worktrees and remote SSH workspaces Solution: - Add runtime parameter to buildSystemMessage() - Read workspace AGENTS.md using Runtime.readFile() (works for SSH) - Fall back to project root AGENTS.md if workspace doesn't have one - Preserves branch-specific instructions for local workspaces - Provides sensible defaults for SSH workspaces during clone Changes: - buildSystemMessage() now accepts Runtime parameter - New readInstructionSetFromRuntime() uses runtime to read workspace files - Instruction priority: global → workspace (if exists) → project (fallback) - Mode section priority: workspace → project → global - Updated docs/instruction-files.md to reflect fallback behavior - Updated all tests to pass runtime parameter - Updated aiService.ts to pass runtime to buildSystemMessage() This preserves existing test behavior (workspace-specific instructions work) while adding robustness for SSH workspaces where files may not exist yet. --- docs/instruction-files.md | 9 +-- src/services/aiService.ts | 1 + src/services/systemMessage.test.ts | 15 +++-- src/services/systemMessage.ts | 91 +++++++++++++++++++++++++----- tests/ipcMain/sendMessage.test.ts | 5 +- 5 files changed, 96 insertions(+), 25 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index e213e3230..de2ee832a 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -2,14 +2,15 @@ ## Overview -cmux layers instructions from two locations: +cmux layers instructions from multiple locations: 1. `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — global defaults -2. `/AGENTS.md` (+ optional `AGENTS.local.md`) — project-specific context +2. `/AGENTS.md` (+ optional `AGENTS.local.md`) — workspace-specific context (if exists) +3. `/AGENTS.md` (+ optional `AGENTS.local.md`) — project fallback 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. -**Note**: Instructions are read from the project root (where the main repository is located), not from individual workspace directories. This ensures consistent instructions across all workspaces for a project. +**Fallback behavior**: If a workspace doesn't have its own AGENTS.md, the project root's AGENTS.md is used as a fallback. This is particularly useful for SSH workspaces where files may not be fully cloned yet. ## Mode Prompts @@ -21,7 +22,7 @@ cmux reads mode context from sections inside your instruction files. Add a headi Rules: -- Project instructions are checked first, then global instructions +- Workspace instructions are checked first, then project, 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 98e94e2e0..fe15e1ca2 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 c5854fc5b..06e5830f5 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -5,6 +5,7 @@ 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; @@ -12,6 +13,7 @@ describe("buildSystemMessage", () => { let workspaceDir: string; let globalDir: string; let mockHomedir: Mock; + let runtime: LocalRuntime; beforeEach(async () => { // Create temp directory for test @@ -26,6 +28,9 @@ describe("buildSystemMessage", () => { // 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 () => { @@ -55,7 +60,7 @@ Use diagrams where appropriate. 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(""); @@ -86,7 +91,7 @@ Focus on planning and design. 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(""); @@ -125,7 +130,7 @@ Project plan instructions (should win). projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, workspaceDir, "plan"); + const systemMessage = await buildSystemMessage(metadata, runtime, workspaceDir, "plan"); // Should include project mode section in the tag (project wins) expect(systemMessage).toMatch(/\s*Project plan instructions \(should win\)\./s); @@ -160,7 +165,7 @@ Just general project stuff. 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"); @@ -181,7 +186,7 @@ Special mode instructions. 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 5cc494fe1..b02ce15fc 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -1,8 +1,10 @@ import * as os from "os"; import * as path from "path"; import type { WorkspaceMetadata } from "@/types/workspace"; -import { gatherInstructionSets, readInstructionSet } from "@/utils/main/instructionFiles"; +import { gatherInstructionSets, readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles"; import { extractModeSection } from "@/utils/main/markdown"; +import type { Runtime } from "@/runtime/Runtime"; +import { readFileString } from "@/utils/runtime/helpers"; // NOTE: keep this in sync with the docs/models.md file @@ -50,15 +52,57 @@ function getSystemDirectory(): string { return path.join(os.homedir(), ".cmux"); } +/** + * Read instruction set from a workspace using the runtime abstraction. + * This supports both local workspaces and remote SSH workspaces. + * + * @param runtime - Runtime instance (may be local or SSH) + * @param workspacePath - Path to workspace directory + * @returns Combined instruction content, or null if no base file exists + */ +async function readInstructionSetFromRuntime( + runtime: Runtime, + workspacePath: string +): Promise { + const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; + + // Try to read base instruction file + let baseContent: string | null = null; + for (const filename of INSTRUCTION_FILE_NAMES) { + try { + const filePath = path.join(workspacePath, filename); + baseContent = await readFileString(runtime, filePath); + break; // Found one, stop searching + } catch { + // File doesn't exist or can't be read, try next + continue; + } + } + + if (!baseContent) { + return null; + } + + // Try to read local variant + try { + const localFilePath = path.join(workspacePath, LOCAL_INSTRUCTION_FILENAME); + const localContent = await readFileString(runtime, localFilePath); + return `${baseContent}\n\n${localContent}`; + } catch { + return baseContent; + } +} + /** * 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. Project instructions: /AGENTS.md (+ AGENTS.local.md) - * 3. Mode-specific context (if mode provided): Extract a section titled "Mode: " + * 2. Workspace instructions: /AGENTS.md (+ AGENTS.local.md) - if exists + * 3. Project instructions: /AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't have one + * 4. 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: project instructions first, then global instructions. + * precedence order: workspace instructions first, then project, then global instructions. * * Each instruction file location is searched for in priority order: * - AGENTS.md @@ -69,6 +113,7 @@ function getSystemDirectory(): string { * checked and appended when building the instruction set (useful for personal preferences not committed to git). * * @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md) + * @param runtime - Runtime instance for reading workspace files (may be remote) * @param workspacePath - Absolute path to the workspace directory (for environment context) * @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 @@ -77,6 +122,7 @@ function getSystemDirectory(): string { */ export async function buildSystemMessage( metadata: WorkspaceMetadata, + runtime: Runtime, workspacePath: string, mode?: string, additionalSystemInstructions?: string @@ -92,20 +138,37 @@ export async function buildSystemMessage( const systemDir = getSystemDirectory(); const projectDir = metadata.projectPath; - // Gather instruction sets from both global and project directories - // Global instructions apply first, then project-specific ones - // Note: We read from projectPath (the main repo) not workspacePath (the worktree) - const instructionDirectories = [systemDir, projectDir]; - const instructionSegments = await gatherInstructionSets(instructionDirectories); - const customInstructions = instructionSegments.join("\n\n"); + // Read workspace instructions using runtime (may be remote for SSH) + // Try to read AGENTS.md from workspace directory first + const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); + + // Gather instruction sets from global and project directories (always local) + // Note: We gather from both systemDir and projectDir, but workspace is handled separately + const localInstructionDirs = [systemDir, projectDir]; + const localInstructionSegments = await gatherInstructionSets(localInstructionDirs); + + // Combine all instruction sources + // Priority: global, workspace (if found), project (as fallback) + const allSegments = [...localInstructionSegments]; + if (workspaceInstructions) { + // Insert workspace instructions after global (index 0) but before project + allSegments.splice(1, 0, workspaceInstructions); + } + const customInstructions = allSegments.join("\n\n"); - // Look for a "Mode: " section inside instruction sets, preferring project over global + // Look for a "Mode: " section inside instruction sets + // Priority: workspace instructions, then project, then global // This behavior is documented in docs/instruction-files.md - keep both in sync when changing. let modeContent: string | null = null; if (mode) { - const projectInstructions = await readInstructionSet(projectDir); - if (projectInstructions) { - modeContent = extractModeSection(projectInstructions, mode); + if (workspaceInstructions) { + modeContent = extractModeSection(workspaceInstructions, mode); + } + if (!modeContent) { + const projectInstructions = await readInstructionSet(projectDir); + if (projectInstructions) { + modeContent = extractModeSection(projectInstructions, mode); + } } if (!modeContent) { const globalInstructions = await readInstructionSet(systemDir); diff --git a/tests/ipcMain/sendMessage.test.ts b/tests/ipcMain/sendMessage.test.ts index f852fbb28..e318f9b12 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 From 0e206ed581dc08b465f94e8da3c319a40d038348 Mon Sep 17 00:00:00 2001 From: Ammar Date: Sun, 26 Oct 2025 20:58:49 +0000 Subject: [PATCH 3/9] Clarify AGENTS.md layering: workspace replaces project, not layers Make it clear that workspace and project instructions are mutually exclusive: - Global instructions are always layered - Workspace instructions REPLACE project instructions (not layered) - Project instructions are only used as fallback when workspace doesn't exist Changes: - Simplified logic to only read project if workspace doesn't exist - Updated comments to emphasize replacement vs layering - Updated documentation to make behavior crystal clear - Mode extraction only checks workspace OR project, not both --- docs/instruction-files.md | 13 +++--- src/services/systemMessage.test.ts | 7 ++- src/services/systemMessage.ts | 68 +++++++++++++----------------- 3 files changed, 43 insertions(+), 45 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index de2ee832a..22c88977f 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -2,15 +2,16 @@ ## Overview -cmux layers instructions from multiple 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 (if exists) -3. `/AGENTS.md` (+ optional `AGENTS.local.md`) — project fallback +1. **Global**: `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — always included +2. **Context**: 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**: If a workspace doesn't have its own AGENTS.md, the project root's AGENTS.md is used as a fallback. This is particularly useful for SSH workspaces where files may not be fully cloned yet. +**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 @@ -22,7 +23,7 @@ cmux reads mode context from sections inside your instruction files. Add a headi Rules: -- Workspace instructions are checked first, then project, then global instructions +- Context 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/systemMessage.test.ts b/src/services/systemMessage.test.ts index 06e5830f5..23554c058 100644 --- a/src/services/systemMessage.test.ts +++ b/src/services/systemMessage.test.ts @@ -186,7 +186,12 @@ Special mode instructions. projectPath: projectDir, }; - const systemMessage = await buildSystemMessage(metadata, runtime, 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 b02ce15fc..03e16b2c0 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -1,7 +1,7 @@ import * as os from "os"; import * as path from "path"; import type { WorkspaceMetadata } from "@/types/workspace"; -import { gatherInstructionSets, readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles"; +import { readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles"; import { extractModeSection } from "@/utils/main/markdown"; import type { Runtime } from "@/runtime/Runtime"; import { readFileString } from "@/utils/runtime/helpers"; @@ -55,7 +55,7 @@ function getSystemDirectory(): string { /** * Read instruction set from a workspace using the runtime abstraction. * This supports both local workspaces and remote SSH workspaces. - * + * * @param runtime - Runtime instance (may be local or SSH) * @param workspacePath - Path to workspace directory * @returns Combined instruction content, or null if no base file exists @@ -96,13 +96,13 @@ async function readInstructionSetFromRuntime( /** * 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) - if exists - * 3. Project instructions: /AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't have one - * 4. 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 project, then global instructions. + * Instruction sources are layered as follows: + * 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md) - always included + * 2. Context instructions: EITHER workspace OR project AGENTS.md (not both) + * - Workspace: /AGENTS.md (+ AGENTS.local.md) - if exists (read via runtime) + * - Project: /AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't exist + * 3. Mode-specific context (if mode provided): Extract a section titled "Mode: " + * (case-insensitive) from the instruction file. Priority: context instructions, then global. * * Each instruction file location is searched for in priority order: * - AGENTS.md @@ -138,43 +138,35 @@ export async function buildSystemMessage( const systemDir = getSystemDirectory(); const projectDir = metadata.projectPath; - // Read workspace instructions using runtime (may be remote for SSH) - // Try to read AGENTS.md from workspace directory first + // Layer 1: Global instructions (always included) + const globalInstructions = await readInstructionSet(systemDir); + + // Layer 2: Workspace OR Project instructions (not both) + // Try workspace first (via runtime, may be remote for SSH) + // Fall back to project if workspace doesn't have AGENTS.md const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); + const projectInstructions = workspaceInstructions ? null : await readInstructionSet(projectDir); - // Gather instruction sets from global and project directories (always local) - // Note: We gather from both systemDir and projectDir, but workspace is handled separately - const localInstructionDirs = [systemDir, projectDir]; - const localInstructionSegments = await gatherInstructionSets(localInstructionDirs); - - // Combine all instruction sources - // Priority: global, workspace (if found), project (as fallback) - const allSegments = [...localInstructionSegments]; - if (workspaceInstructions) { - // Insert workspace instructions after global (index 0) but before project - allSegments.splice(1, 0, workspaceInstructions); - } - const customInstructions = allSegments.join("\n\n"); + // Combine instruction sources + // Result: global + (workspace OR project) + const instructionSegments = [ + globalInstructions, + workspaceInstructions ?? projectInstructions, + ].filter(Boolean); + const customInstructions = instructionSegments.join("\n\n"); // Look for a "Mode: " section inside instruction sets - // Priority: workspace instructions, then project, then global + // Priority: workspace (or project fallback), then global + // We only check the workspace OR project instructions, not both // This behavior is documented in docs/instruction-files.md - keep both in sync when changing. let modeContent: string | null = null; if (mode) { - if (workspaceInstructions) { - modeContent = extractModeSection(workspaceInstructions, mode); - } - if (!modeContent) { - const projectInstructions = await readInstructionSet(projectDir); - if (projectInstructions) { - modeContent = extractModeSection(projectInstructions, mode); - } + const contextInstructions = workspaceInstructions ?? projectInstructions; + if (contextInstructions) { + modeContent = extractModeSection(contextInstructions, mode); } - if (!modeContent) { - const globalInstructions = await readInstructionSet(systemDir); - if (globalInstructions) { - modeContent = extractModeSection(globalInstructions, mode); - } + if (!modeContent && globalInstructions) { + modeContent = extractModeSection(globalInstructions, mode); } } From 61032cdf91455d4fc349b0a47a04a5fb9b7c3519 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:07:50 +0000 Subject: [PATCH 4/9] docs: rename 'Context' to 'Project' for clarity --- docs/instruction-files.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/instruction-files.md b/docs/instruction-files.md index 22c88977f..fbdb3a26c 100644 --- a/docs/instruction-files.md +++ b/docs/instruction-files.md @@ -5,7 +5,7 @@ cmux layers instructions from two sources: 1. **Global**: `~/.cmux/AGENTS.md` (+ optional `AGENTS.local.md`) — always included -2. **Context**: Either workspace OR project AGENTS.md (not both): +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 @@ -23,7 +23,7 @@ cmux reads mode context from sections inside your instruction files. Add a headi Rules: -- Context instructions (workspace or project fallback) 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) From 77ae24651b23fd7dddba46233c3eef5e1cb5d73b Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:13:06 +0000 Subject: [PATCH 5/9] refactor: DRY local variant pattern for runtime reads Extract readFileWithLocalVariantFromRuntime() to match the pattern used by readFileWithLocalVariant() for local files. This eliminates code duplication and makes the runtime-based reading follow the same structure. --- src/services/systemMessage.ts | 72 +++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index 03e16b2c0..553053b7f 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -53,39 +53,58 @@ function getSystemDirectory(): string { } /** - * Read instruction set from a workspace using the runtime abstraction. - * This supports both local workspaces and remote SSH workspaces. + * Read the first available file from a list using runtime. * * @param runtime - Runtime instance (may be local or SSH) - * @param workspacePath - Path to workspace directory - * @returns Combined instruction content, or null if no base file exists + * @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 */ -async function readInstructionSetFromRuntime( +async function readFirstAvailableFileFromRuntime( runtime: Runtime, - workspacePath: string + directory: string, + filenames: readonly string[] ): Promise { - const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; - - // Try to read base instruction file - let baseContent: string | null = null; - for (const filename of INSTRUCTION_FILE_NAMES) { + for (const filename of filenames) { try { - const filePath = path.join(workspacePath, filename); - baseContent = await readFileString(runtime, filePath); - break; // Found one, stop searching + const filePath = path.join(directory, filename); + return await readFileString(runtime, filePath); } catch { // File doesn't exist or can't be read, try next continue; } } + return null; +} + +/** + * Read a file with optional local variant using runtime. + * Follows the same pattern as readFileWithLocalVariant but uses Runtime. + * + * @param runtime - Runtime instance (may be local or SSH) + * @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 + */ +async function readFileWithLocalVariantFromRuntime( + runtime: Runtime, + directory: string, + baseFilenames: readonly string[], + localFilename?: string +): Promise { + const baseContent = await readFirstAvailableFileFromRuntime(runtime, directory, baseFilenames); if (!baseContent) { return null; } - // Try to read local variant + if (!localFilename) { + return baseContent; + } + try { - const localFilePath = path.join(workspacePath, LOCAL_INSTRUCTION_FILENAME); + const localFilePath = path.join(directory, localFilename); const localContent = await readFileString(runtime, localFilePath); return `${baseContent}\n\n${localContent}`; } catch { @@ -93,6 +112,27 @@ async function readInstructionSetFromRuntime( } } +/** + * Read instruction set from a workspace using the runtime abstraction. + * This supports both local workspaces and remote SSH workspaces. + * + * @param runtime - Runtime instance (may be local or SSH) + * @param workspacePath - Path to workspace directory + * @returns Combined instruction content, or null if no base file exists + */ +async function readInstructionSetFromRuntime( + runtime: Runtime, + workspacePath: string +): Promise { + const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; + return readFileWithLocalVariantFromRuntime( + runtime, + workspacePath, + INSTRUCTION_FILE_NAMES, + LOCAL_INSTRUCTION_FILENAME + ); +} + /** * Builds a system message for the AI model by combining multiple instruction sources. * From a833cb29fd7ac2c90f20dad18a1f014682531f9b Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:23:45 +0000 Subject: [PATCH 6/9] refactor: unify instruction file reading with FileReader abstraction - Created FileReader interface to eliminate duplication between fs and Runtime - Moved all instruction reading logic to instructionFiles.ts - Removed 117 lines from systemMessage.ts (-49%) - Simplified helper function structure with shared implementation - Removed redundant tests for private helpers (covered by public API) Net change: -113 lines across 3 files (-28%) All tests passing (16/16) --- src/services/systemMessage.ts | 188 +++++------------------- src/utils/main/instructionFiles.test.ts | 32 ---- src/utils/main/instructionFiles.ts | 101 ++++++++----- 3 files changed, 104 insertions(+), 217 deletions(-) diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index 553053b7f..cc5bcaf31 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -1,10 +1,9 @@ import * as os from "os"; import * as path from "path"; import type { WorkspaceMetadata } from "@/types/workspace"; -import { readInstructionSet, INSTRUCTION_FILE_NAMES } from "@/utils/main/instructionFiles"; +import { readInstructionSet, readInstructionSetFromRuntime } from "@/utils/main/instructionFiles"; import { extractModeSection } from "@/utils/main/markdown"; import type { Runtime } from "@/runtime/Runtime"; -import { readFileString } from "@/utils/runtime/helpers"; // NOTE: keep this in sync with the docs/models.md file @@ -30,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 ` @@ -44,121 +46,30 @@ 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"); } /** - * Read the first available file from a list using runtime. + * Builds a system message for the AI model by combining instruction sources. * - * @param runtime - Runtime instance (may be local or SSH) - * @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 - */ -async function readFirstAvailableFileFromRuntime( - runtime: Runtime, - directory: string, - filenames: readonly string[] -): Promise { - for (const filename of filenames) { - try { - const filePath = path.join(directory, filename); - return await readFileString(runtime, filePath); - } catch { - // File doesn't exist or can't be read, try next - continue; - } - } - return null; -} - -/** - * Read a file with optional local variant using runtime. - * Follows the same pattern as readFileWithLocalVariant but uses Runtime. + * 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) * - * @param runtime - Runtime instance (may be local or SSH) - * @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 - */ -async function readFileWithLocalVariantFromRuntime( - runtime: Runtime, - directory: string, - baseFilenames: readonly string[], - localFilename?: string -): Promise { - const baseContent = await readFirstAvailableFileFromRuntime(runtime, directory, baseFilenames); - - if (!baseContent) { - return null; - } - - if (!localFilename) { - return baseContent; - } - - try { - const localFilePath = path.join(directory, localFilename); - const localContent = await readFileString(runtime, localFilePath); - return `${baseContent}\n\n${localContent}`; - } catch { - return baseContent; - } -} - -/** - * Read instruction set from a workspace using the runtime abstraction. - * This supports both local workspaces and remote SSH workspaces. + * File search order: AGENTS.md → AGENT.md → CLAUDE.md + * Local variants: AGENTS.local.md appended if found (for .gitignored personal preferences) * - * @param runtime - Runtime instance (may be local or SSH) - * @param workspacePath - Path to workspace directory - * @returns Combined instruction content, or null if no base file exists - */ -async function readInstructionSetFromRuntime( - runtime: Runtime, - workspacePath: string -): Promise { - const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; - return readFileWithLocalVariantFromRuntime( - runtime, - workspacePath, - INSTRUCTION_FILE_NAMES, - LOCAL_INSTRUCTION_FILENAME - ); -} - -/** - * Builds a system message for the AI model by combining multiple instruction sources. - * - * Instruction sources are layered as follows: - * 1. Global instructions: ~/.cmux/AGENTS.md (+ AGENTS.local.md) - always included - * 2. Context instructions: EITHER workspace OR project AGENTS.md (not both) - * - Workspace: /AGENTS.md (+ AGENTS.local.md) - if exists (read via runtime) - * - Project: /AGENTS.md (+ AGENTS.local.md) - fallback if workspace doesn't exist - * 3. Mode-specific context (if mode provided): Extract a section titled "Mode: " - * (case-insensitive) from the instruction file. Priority: context instructions, then global. - * - * Each instruction file location is searched for in priority order: - * - AGENTS.md - * - AGENT.md - * - CLAUDE.md - * - * 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). - * - * @param metadata - Workspace metadata (contains projectPath for reading AGENTS.md) - * @param runtime - Runtime instance for reading workspace files (may be remote) - * @param workspacePath - Absolute path to the workspace directory (for environment context) - * @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, @@ -167,67 +78,40 @@ export async function buildSystemMessage( 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 projectDir = metadata.projectPath; + // Read instruction sets + const globalInstructions = await readInstructionSet(getSystemDirectory()); + const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); + const contextInstructions = workspaceInstructions ?? (await readInstructionSet(metadata.projectPath)); - // Layer 1: Global instructions (always included) - const globalInstructions = await readInstructionSet(systemDir); + // Combine: global + context (workspace takes precedence over project) + const customInstructions = [globalInstructions, contextInstructions] + .filter(Boolean) + .join("\n\n"); - // Layer 2: Workspace OR Project instructions (not both) - // Try workspace first (via runtime, may be remote for SSH) - // Fall back to project if workspace doesn't have AGENTS.md - const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); - const projectInstructions = workspaceInstructions ? null : await readInstructionSet(projectDir); - - // Combine instruction sources - // Result: global + (workspace OR project) - const instructionSegments = [ - globalInstructions, - workspaceInstructions ?? projectInstructions, - ].filter(Boolean); - const customInstructions = instructionSegments.join("\n\n"); - - // Look for a "Mode: " section inside instruction sets - // Priority: workspace (or project fallback), then global - // We only check the workspace OR project instructions, not both - // 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 contextInstructions = workspaceInstructions ?? projectInstructions; - if (contextInstructions) { - modeContent = extractModeSection(contextInstructions, mode); - } - if (!modeContent && globalInstructions) { - modeContent = extractModeSection(globalInstructions, mode); - } + modeContent = + (contextInstructions && extractModeSection(contextInstructions, mode)) || + (globalInstructions && extractModeSection(globalInstructions, mode)) || + null; } - // Build the final system message - // Use workspacePath for environment context (where code actually executes) - const environmentContext = buildEnvironmentContext(workspacePath); - 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 f3d7e227c..ecfc452d0 100644 --- a/src/utils/main/instructionFiles.test.ts +++ b/src/utils/main/instructionFiles.test.ts @@ -2,10 +2,8 @@ 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"; describe("instructionFiles", () => { @@ -19,36 +17,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 e16136ba8..4c798f7d9 100644 --- a/src/utils/main/instructionFiles.ts +++ b/src/utils/main/instructionFiles.ts @@ -1,5 +1,8 @@ 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,27 +16,51 @@ 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"; + +/** + * File reader abstraction for reading files from either local fs or Runtime. + */ +type FileReader = { + readFile(filePath: string): Promise; +}; + +/** + * Create a FileReader for local filesystem access. + */ +function createLocalFileReader(): FileReader { + return { + readFile: (filePath: string) => fs.readFile(filePath, "utf-8"), + }; +} /** - * Attempts to read the first available file from a list of filenames in a directory. + * 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; @@ -47,47 +74,37 @@ export async function readFirstAvailableFile( */ /** - * 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 +112,26 @@ 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); } /** From f89f96e72e579c19da01c03c9f7ade38ab8c1c28 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:25:57 +0000 Subject: [PATCH 7/9] fix: remove orphaned JSDoc comment --- src/utils/main/instructionFiles.ts | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/utils/main/instructionFiles.ts b/src/utils/main/instructionFiles.ts index 4c798f7d9..04a5911ae 100644 --- a/src/utils/main/instructionFiles.ts +++ b/src/utils/main/instructionFiles.ts @@ -66,13 +66,6 @@ async function readFirstAvailableFile( 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). - */ - /** * Read a base file with optional local variant using the provided file reader. * From 566d3ce979819e4b45d7c703fde39ebc4319c806 Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:27:44 +0000 Subject: [PATCH 8/9] fix: address eslint violations (interface over type, nullish coalescing) --- src/services/systemMessage.ts | 4 ++-- src/utils/main/instructionFiles.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index cc5bcaf31..5f696e787 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -95,8 +95,8 @@ export async function buildSystemMessage( let modeContent: string | null = null; if (mode) { modeContent = - (contextInstructions && extractModeSection(contextInstructions, mode)) || - (globalInstructions && extractModeSection(globalInstructions, mode)) || + (contextInstructions && extractModeSection(contextInstructions, mode)) ?? + (globalInstructions && extractModeSection(globalInstructions, mode)) ?? null; } diff --git a/src/utils/main/instructionFiles.ts b/src/utils/main/instructionFiles.ts index 04a5911ae..6bcbb9628 100644 --- a/src/utils/main/instructionFiles.ts +++ b/src/utils/main/instructionFiles.ts @@ -21,9 +21,9 @@ export const LOCAL_INSTRUCTION_FILENAME = "AGENTS.local.md"; /** * File reader abstraction for reading files from either local fs or Runtime. */ -type FileReader = { +interface FileReader { readFile(filePath: string): Promise; -}; +} /** * Create a FileReader for local filesystem access. From aff34760407f3cfb7ddf40029b2c33e25522229a Mon Sep 17 00:00:00 2001 From: Ammar Date: Mon, 27 Oct 2025 01:29:18 +0000 Subject: [PATCH 9/9] chore: run prettier to fix formatting --- src/services/systemMessage.ts | 7 +++---- src/utils/main/instructionFiles.test.ts | 5 +---- src/utils/main/instructionFiles.ts | 19 +++++++++++++++---- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/services/systemMessage.ts b/src/services/systemMessage.ts index 5f696e787..ed3988750 100644 --- a/src/services/systemMessage.ts +++ b/src/services/systemMessage.ts @@ -84,12 +84,11 @@ export async function buildSystemMessage( // Read instruction sets const globalInstructions = await readInstructionSet(getSystemDirectory()); const workspaceInstructions = await readInstructionSetFromRuntime(runtime, workspacePath); - const contextInstructions = workspaceInstructions ?? (await readInstructionSet(metadata.projectPath)); + const contextInstructions = + workspaceInstructions ?? (await readInstructionSet(metadata.projectPath)); // Combine: global + context (workspace takes precedence over project) - const customInstructions = [globalInstructions, contextInstructions] - .filter(Boolean) - .join("\n\n"); + const customInstructions = [globalInstructions, contextInstructions].filter(Boolean).join("\n\n"); // Extract mode-specific section (context first, then global fallback) let modeContent: string | null = null; diff --git a/src/utils/main/instructionFiles.test.ts b/src/utils/main/instructionFiles.test.ts index ecfc452d0..9326cf463 100644 --- a/src/utils/main/instructionFiles.test.ts +++ b/src/utils/main/instructionFiles.test.ts @@ -1,10 +1,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; -import { - readInstructionSet, - gatherInstructionSets, -} from "./instructionFiles"; +import { readInstructionSet, gatherInstructionSets } from "./instructionFiles"; describe("instructionFiles", () => { let tempDir: string; diff --git a/src/utils/main/instructionFiles.ts b/src/utils/main/instructionFiles.ts index 6bcbb9628..c5313938a 100644 --- a/src/utils/main/instructionFiles.ts +++ b/src/utils/main/instructionFiles.ts @@ -3,7 +3,6 @@ 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. * The first file found in a directory is used as the base instruction set. @@ -105,10 +104,17 @@ 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 | null | undefined): Promise { +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); + return readFileWithLocalVariant( + reader, + path.resolve(directory), + INSTRUCTION_FILE_NAMES, + LOCAL_INSTRUCTION_FILENAME + ); } /** @@ -124,7 +130,12 @@ export async function readInstructionSetFromRuntime( directory: string ): Promise { const reader = createRuntimeFileReader(runtime); - return readFileWithLocalVariant(reader, directory, INSTRUCTION_FILE_NAMES, LOCAL_INSTRUCTION_FILENAME); + return readFileWithLocalVariant( + reader, + directory, + INSTRUCTION_FILE_NAMES, + LOCAL_INSTRUCTION_FILENAME + ); } /**