Skip to content
Merged
12 changes: 8 additions & 4 deletions docs/instruction-files.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `<workspace>/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**: `<workspace>/AGENTS.md` (+ optional `AGENTS.local.md`) — if exists
- **Project**: `<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.
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ export class AIService extends EventEmitter {
// Build system message from workspace metadata
const systemMessage = await buildSystemMessage(
metadata,
runtime,
workspacePath,
mode,
additionalSystemInstructions
Expand Down
69 changes: 41 additions & 28 deletions src/services/systemMessage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof os.homedir>;
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 () => {
Expand All @@ -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.

Expand All @@ -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("<plan>");
Expand All @@ -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.

Expand All @@ -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 <plan> mode-specific tag
expect(systemMessage).not.toContain("<plan>");
Expand All @@ -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"),
Expand All @@ -105,33 +113,33 @@ 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).
`
);

const metadata: WorkspaceMetadata = {
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 <plan> tag (workspace wins)
expect(systemMessage).toMatch(/<plan>\s*Workspace plan instructions \(should win\)\./s);
// Should include project mode section in the <plan> tag (project wins)
expect(systemMessage).toMatch(/<plan>\s*Project plan instructions \(should win\)\./s);
// Global instructions are still present in <custom-instructions> section (that's correct)
// But the mode-specific <plan> section should only have workspace content
// But the mode-specific <plan> section should only have project content
expect(systemMessage).not.toMatch(/<plan>[^<]*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"),
Expand All @@ -142,30 +150,30 @@ 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.
`
);

const metadata: WorkspaceMetadata = {
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");
});

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.
`
Expand All @@ -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("<my-special_mode->");
Expand Down
94 changes: 37 additions & 57 deletions src/services/systemMessage.ts
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -28,6 +29,9 @@ Use GitHub-style \`<details>/<summary>\` tags to create collapsible sections for
</prelude>
`;

/**
* Build environment context XML block describing the workspace.
*/
function buildEnvironmentContext(workspacePath: string): string {
return `
<environment>
Expand All @@ -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: <workspace>/AGENTS.md (+ AGENTS.local.md)
* 3. Mode-specific context (if mode provided): Extract a section titled "Mode: <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: <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<string> {
// 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: <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<custom-instructions>\n${customInstructions}\n</custom-instructions>`;
}

// 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</${tag}>`;
}

// Add additional system instructions at the end (highest priority)
if (additionalSystemInstructions) {
systemMessage += `\n\n<additional-instructions>\n${additionalSystemInstructions}\n</additional-instructions>`;
}
Expand Down
Loading