Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,6 @@ rulesync.local.jsonc
**/.windsurf/hooks.json
**/.devin/skills/
**/.codeium/windsurf/skills/
**/.warp/
**/.warp/.mcp.json
**/.warp/skills/
**/.rules
5 changes: 3 additions & 2 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,8 +373,9 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" },

// Warp
// `/init` now writes `AGENTS.md` (handled by the shared AGENTS.md entry above).
{ target: "warp", feature: "rules", entry: "**/.warp/" },
// Warp reads project rules only from the root `AGENTS.md` (handled by the
// shared AGENTS.md entry above); it does not read `.warp/memories/`, so no
// rules entry under `.warp/` is emitted.
{ target: "warp", feature: "mcp", entry: "**/.warp/.mcp.json" },
{ target: "warp", feature: "skills", entry: "**/.warp/skills/" },

Expand Down
7 changes: 6 additions & 1 deletion src/cli/commands/gitignore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,12 @@ describe("gitignoreCommand", () => {
expect(content).toContain("**/.github/agents/");
expect(content).toContain("**/.github/hooks/");
expect(content).toContain("**/.github/prompts/");
expect(content).toContain("**/.warp/");
expect(content).toContain("**/.warp/.mcp.json");
expect(content).toContain("**/.warp/skills/");
// Warp rules live in the root AGENTS.md, not `.warp/memories/`, so no bare
// `.warp/` rules entry is emitted (checked line-wise to avoid matching the
// `**/.warp/.mcp.json` prefix).
expect(content.split("\n").map((line) => line.trim())).not.toContain("**/.warp/");
expect(content).toContain("**/.codex/memories/");
expect(content).toContain("**/.agents/skills/");
expect(content).toContain("**/.deepagents/AGENTS.md");
Expand Down
32 changes: 17 additions & 15 deletions src/features/rules/rules-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -746,13 +746,15 @@ export class RulesProcessor extends FeatureProcessor {
})
.filter((rule): rule is ToolRule => rule !== null);

// deepagents (dcode) reads project context only from the single
// `.deepagents/AGENTS.md` file; it neither scans a `memories/` directory nor
// follows `@`-style references. Fold every non-root rule body into the root
// rule so no rule content is silently lost, and drop the now-redundant
// non-root instances (which all share the root path).
if (this.toolTarget === "deepagents") {
this.foldDeepagentsNonRootRules(toolRules);
// Some tools read project rules only from a single root `AGENTS.md` file and
// neither scan a `memories/` directory nor follow references out of it:
// - deepagents (dcode) reads only `.deepagents/AGENTS.md`.
// - Warp reads only root/subdir `AGENTS.md`, never `.warp/memories/`.
// Fold every non-root rule body into the root rule so no rule content is
// silently lost, and drop the now-redundant non-root instances (which all
// share the root path).
if (this.toolTarget === "deepagents" || this.toolTarget === "warp") {
this.foldNonRootRulesIntoRootRule(toolRules);
}

const includeLocalRoot = resolveIncludeLocalRoot(this.featureOptions);
Expand Down Expand Up @@ -916,20 +918,20 @@ export class RulesProcessor extends FeatureProcessor {
}

/**
* Fold every deepagents rule body into a single `.deepagents/AGENTS.md`.
* Fold every non-root rule body into the single root rule file.
*
* The deepagents (dcode) MemoryMiddleware loads project context only from the
* fixed `.deepagents/AGENTS.md` (and root `AGENTS.md`) files and never scans a
* `memories/` directory or follows references. `DeepagentsRule` therefore emits
* both root and non-root rules to the same `.deepagents/AGENTS.md` path, so all
* rule bodies must be merged into one instance to avoid colliding on that path
* (last-writer-wins would silently drop content).
* Used for tools whose rules engine reads only one root `AGENTS.md` and neither
* scans a `memories/` directory nor follows references (deepagents' dcode reads
* `.deepagents/AGENTS.md`; Warp reads root/subdir `AGENTS.md` but never
* `.warp/memories/`). Those rule classes emit both root and non-root rules to
* the same root path, so all bodies must be merged into one instance to avoid
* colliding on that path (last-writer-wins would silently drop content).
*
* The root rule (if any) becomes the merge target and leads the merged content;
* otherwise the first rule is used so a rule set without a root overview still
* produces a single, complete file. Mutates `toolRules` in place.
*/
private foldDeepagentsNonRootRules(toolRules: ToolRule[]): void {
private foldNonRootRulesIntoRootRule(toolRules: ToolRule[]): void {
if (toolRules.length <= 1) {
return;
}
Expand Down
57 changes: 28 additions & 29 deletions src/features/rules/warp-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
RULESYNC_RULES_RELATIVE_DIR_PATH,
} from "../../constants/rulesync-paths.js";
import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { ensureDir, writeFileContent } from "../../utils/file.js";
import { writeFileContent } from "../../utils/file.js";
import { RulesyncRule, type RulesyncRuleFrontmatterInput } from "./rulesync-rule.js";
import { WarpRule, type WarpRuleParams } from "./warp-rule.js";

Expand Down Expand Up @@ -131,22 +131,22 @@ describe("WarpRule", () => {
expect(warpRule.getFilePath()).toBe(join(testDir, "AGENTS.md"));
});

it("should create WarpRule from memory file in .warp/memories", async () => {
const memoryContent = "# Memory File\n\nThis is a memory file.";
const memoriesDir = join(testDir, ".warp/memories");
await ensureDir(memoriesDir);
await writeFileContent(join(memoriesDir, "test-memory.md"), memoryContent);
it("should always read the root AGENTS.md, ignoring the requested relativeFilePath", async () => {
// Warp reads rules only from the root AGENTS.md; fromFile therefore reads
// that file regardless of the relativeFilePath it is asked for.
const rootContent = "# Root\n\nWarp reads only this file.";
await writeFileContent(join(testDir, "AGENTS.md"), rootContent);

const warpRule = await WarpRule.fromFile({
outputRoot: testDir,
relativeFilePath: "test-memory.md",
relativeFilePath: "some-memory.md",
});

expect(warpRule.isRoot()).toBe(false);
expect(warpRule.getRelativeDirPath()).toBe(".warp");
expect(warpRule.getRelativeFilePath()).toBe("test-memory.md");
expect(warpRule.getFileContent()).toBe(memoryContent);
expect(warpRule.getFilePath()).toBe(join(testDir, ".warp/test-memory.md"));
expect(warpRule.isRoot()).toBe(true);
expect(warpRule.getRelativeDirPath()).toBe(".");
expect(warpRule.getRelativeFilePath()).toBe("AGENTS.md");
expect(warpRule.getFileContent()).toBe(rootContent);
expect(warpRule.getFilePath()).toBe(join(testDir, "AGENTS.md"));
});

it("should use default outputRoot (process.cwd()) when not provided", async () => {
Expand Down Expand Up @@ -210,13 +210,13 @@ describe("WarpRule", () => {
expect(warpRule.isRoot()).toBe(true);
});

it("should create WarpRule from RulesyncRule for memory file", () => {
it("should target the root AGENTS.md for a non-root rule (folded later by the processor)", () => {
const frontmatter: RulesyncRuleFrontmatterInput = {
description: "Test memory rule",
};

const rulesyncRule = new RulesyncRule({
relativeDirPath: ".warp/memories",
relativeDirPath: ".rulesync/rules",
relativeFilePath: "memory.md",
frontmatter,
body: "# Memory Rule\n\nMemory content",
Expand All @@ -229,9 +229,12 @@ describe("WarpRule", () => {

expect(warpRule).toBeInstanceOf(WarpRule);
expect(warpRule.getOutputRoot()).toBe(testDir);
expect(warpRule.getRelativeDirPath()).toBe(".warp/memories");
expect(warpRule.getRelativeFilePath()).toBe("memory.md");
// Non-root rules resolve to the single root AGENTS.md; the RulesProcessor
// folds their bodies into the root rule before writing.
expect(warpRule.getRelativeDirPath()).toBe(".");
expect(warpRule.getRelativeFilePath()).toBe("AGENTS.md");
expect(warpRule.isRoot()).toBe(false);
expect(warpRule.getFileContent()).toBe("# Memory Rule\n\nMemory content");
});

it("should use default outputRoot (process.cwd()) when not provided", () => {
Expand Down Expand Up @@ -365,44 +368,40 @@ describe("WarpRule", () => {
expect(warpRule.getRelativeDirPath()).toBe(".");
});

it("should correctly handle non-root files in fromFile", async () => {
const content = "# Memory File";
const memoriesDir = join(testDir, ".warp/memories");
await ensureDir(memoriesDir);
await writeFileContent(join(memoriesDir, "memory.md"), content);
it("should read the root AGENTS.md for any requested file in fromFile", async () => {
const content = "# Root File";
await writeFileContent(join(testDir, "AGENTS.md"), content);

const warpRule = await WarpRule.fromFile({
outputRoot: testDir,
relativeFilePath: "memory.md",
});

expect(warpRule.isRoot()).toBe(false);
expect(warpRule.getRelativeDirPath()).toBe(".warp");
expect(warpRule.isRoot()).toBe(true);
expect(warpRule.getRelativeDirPath()).toBe(".");
expect(warpRule.getRelativeFilePath()).toBe("AGENTS.md");
});
});

describe("getSettablePaths", () => {
it("should return correct paths for root and nonRoot", () => {
it("should return only the root path (no non-root location)", () => {
const paths = WarpRule.getSettablePaths();

expect(paths.root).toEqual({
relativeDirPath: ".",
relativeFilePath: "AGENTS.md",
});

expect(paths.nonRoot).toEqual({
relativeDirPath: ".warp/memories",
});
// Warp does not read `.warp/memories/`, so there is no non-root location.
expect(paths.nonRoot).toBeUndefined();
});

it("should have consistent paths structure", () => {
const paths = WarpRule.getSettablePaths();

expect(paths).toHaveProperty("root");
expect(paths).toHaveProperty("nonRoot");
expect(paths.root).toHaveProperty("relativeDirPath");
expect(paths.root).toHaveProperty("relativeFilePath");
expect(paths.nonRoot).toHaveProperty("relativeDirPath");
});
});

Expand Down
66 changes: 39 additions & 27 deletions src/features/rules/warp-rule.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { join } from "node:path";

import { WARP_DIR, WARP_RULE_FILE_NAME } from "../../constants/warp-paths.js";
import { WARP_RULE_FILE_NAME } from "../../constants/warp-paths.js";
import { AiFileParams, ValidationResult } from "../../types/ai-file.js";
import { readFileContent } from "../../utils/file.js";
import { RulesyncRule } from "./rulesync-rule.js";
Expand All @@ -10,21 +10,29 @@ import {
ToolRuleFromFileParams,
ToolRuleFromRulesyncRuleParams,
ToolRuleSettablePaths,
buildToolPath,
} from "./tool-rule.js";

export type WarpRuleParams = AiFileParams & {
root?: boolean;
};

export type WarpRuleSettablePaths = Omit<ToolRuleSettablePaths, "root"> & {
/**
* Warp reads project rules only from `AGENTS.md` (or the back-compat `WARP.md`)
* at the repository root and in subdirectories that match the project tree — it
* does NOT scan a `.warp/memories/` directory and does not follow references out
* of a rules file. rulesync's topic-based non-root rules have no project
* subdirectory to map onto, so their bodies are folded into the single root
* `./AGENTS.md` by the RulesProcessor; there is no separate non-root output
* location (`nonRoot` is `undefined`).
*
* @see https://docs.warp.dev/agent-platform/capabilities/rules/
*/
export type WarpRuleSettablePaths = Pick<ToolRuleSettablePaths, "root"> & {
root: {
relativeDirPath: string;
relativeFilePath: string;
};
nonRoot: {
relativeDirPath: string;
};
nonRoot?: undefined;
};

export class WarpRule extends ToolRule {
Expand All @@ -47,30 +55,27 @@ export class WarpRule extends ToolRule {
relativeDirPath: ".",
relativeFilePath: WARP_RULE_FILE_NAME,
},
nonRoot: {
relativeDirPath: buildToolPath(WARP_DIR, "memories", _options.excludeToolDir),
},
};
}

static async fromFile({
outputRoot = process.cwd(),
relativeFilePath,
// Warp reads rules only from the root `AGENTS.md`, so the incoming
// `relativeFilePath` is ignored and the root file is read.
relativeFilePath: _relativeFilePath,
validate = true,
}: ToolRuleFromFileParams): Promise<WarpRule> {
const isRoot = relativeFilePath === this.getSettablePaths().root.relativeFilePath;
const relativePath = isRoot
? this.getSettablePaths().root.relativeFilePath
: join(this.getSettablePaths().nonRoot.relativeDirPath, relativeFilePath);
const { root } = this.getSettablePaths();
const relativePath = join(root.relativeDirPath, root.relativeFilePath);
const fileContent = await readFileContent(join(outputRoot, relativePath));

return new WarpRule({
outputRoot,
relativeDirPath: isRoot ? this.getSettablePaths().root.relativeDirPath : WARP_DIR,
relativeFilePath: isRoot ? this.getSettablePaths().root.relativeFilePath : relativeFilePath,
relativeDirPath: root.relativeDirPath,
relativeFilePath: root.relativeFilePath,
fileContent,
validate,
root: isRoot,
root: true,
});
}

Expand All @@ -79,15 +84,20 @@ export class WarpRule extends ToolRule {
rulesyncRule,
validate = true,
}: ToolRuleFromRulesyncRuleParams): WarpRule {
return new WarpRule(
this.buildToolRuleParamsAgentsmd({
outputRoot,
rulesyncRule,
validate,
rootPath: this.getSettablePaths().root,
nonRootPath: this.getSettablePaths().nonRoot,
}),
);
const { root } = this.getSettablePaths();
const isRoot = rulesyncRule.getFrontmatter().root ?? false;

// Both root and non-root rules target the single root `./AGENTS.md`; the
// RulesProcessor folds the non-root bodies (`root: false`) into the root rule
// and drops the redundant non-root instances before writing.
return new WarpRule({
outputRoot,
relativeDirPath: root.relativeDirPath,
relativeFilePath: root.relativeFilePath,
fileContent: rulesyncRule.getBody(),
validate,
root: isRoot,
});
}

toRulesyncRule(): RulesyncRule {
Expand All @@ -103,7 +113,9 @@ export class WarpRule extends ToolRule {
relativeDirPath,
relativeFilePath,
}: ToolRuleForDeletionParams): WarpRule {
const isRoot = relativeFilePath === this.getSettablePaths().root.relativeFilePath;
const { root } = this.getSettablePaths();
const isRoot =
relativeFilePath === root.relativeFilePath && relativeDirPath === root.relativeDirPath;

return new WarpRule({
outputRoot,
Expand Down