From 3a57ea7a9e4f0a0070ff343e1c139bf0ad562a3d Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 15 Jun 2026 02:31:23 -0700 Subject: [PATCH 1/2] fix(warp): fold non-root rules into root AGENTS.md instead of inert .warp/memories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up from #1825 (Warp upstream updates). Warp reads project rules only from `AGENTS.md` (or back-compat `WARP.md`) at the repo root and in project subdirectories — it never scans a `.warp/memories/` directory (verified against Warp's current rules docs). rulesync was routing all non-root Warp rules to `.warp/memories/.md`, a path Warp does not read, so that content was silently inert. - `WarpRule` now mirrors the deepagents model: `getSettablePaths` exposes only the root `./AGENTS.md` (no `nonRoot` location), and both root and non-root rules target that single file. `fromFile` always reads the root `AGENTS.md`. - Generalize the RulesProcessor fold helper (`foldNonRootRulesIntoRootRule`, formerly deepagents-specific) and apply it to `warp` so non-root rule bodies are merged into the root `AGENTS.md` rather than dropped. - Drop the now-stale `.warp/` rules gitignore entry (warp rules live in the shared `AGENTS.md` entry; mcp/skills keep their own `.warp/*` entries) and regenerate `.gitignore`. - Update WarpRule unit tests for the root-only behavior. Out of scope for this PR: - Agent permissions (settings.toml `[agents.profiles]`) — already implemented in `warp-permissions.ts` after the issue was filed; no action needed. - Emitting the additional `.agents/.mcp.json` MCP location — the canonical `.warp/.mcp.json` remains fully valid, so this stays a low-priority follow-up. Closes #1825 --- .gitignore | 3 +- src/cli/commands/gitignore-entries.ts | 5 +- src/features/rules/rules-processor.ts | 32 +++++++------ src/features/rules/warp-rule.test.ts | 57 ++++++++++++----------- src/features/rules/warp-rule.ts | 66 ++++++++++++++++----------- 5 files changed, 89 insertions(+), 74 deletions(-) diff --git a/.gitignore b/.gitignore index 6b9b9d77f..9e753f50e 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,8 @@ mcp-schema.json docs/.vitepress/dist docs/.vitepress/cache +**/.warp/ + # Generated by Rulesync .rulesync/skills/.curated/ .rulesync/rules/*.local.md @@ -349,7 +351,6 @@ rulesync.local.jsonc **/.windsurf/hooks.json **/.devin/skills/ **/.codeium/windsurf/skills/ -**/.warp/ **/.warp/.mcp.json **/.warp/skills/ **/.rules diff --git a/src/cli/commands/gitignore-entries.ts b/src/cli/commands/gitignore-entries.ts index 274e6460f..c1c44e81a 100644 --- a/src/cli/commands/gitignore-entries.ts +++ b/src/cli/commands/gitignore-entries.ts @@ -373,8 +373,9 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray = [ { 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/" }, diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 247afc204..81112a00e 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -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); @@ -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; } diff --git a/src/features/rules/warp-rule.test.ts b/src/features/rules/warp-rule.test.ts index e0773a27e..16c42051a 100644 --- a/src/features/rules/warp-rule.test.ts +++ b/src/features/rules/warp-rule.test.ts @@ -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"; @@ -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 () => { @@ -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", @@ -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", () => { @@ -365,24 +368,23 @@ 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({ @@ -390,19 +392,16 @@ describe("WarpRule", () => { 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"); }); }); diff --git a/src/features/rules/warp-rule.ts b/src/features/rules/warp-rule.ts index 135e4c885..f9dbe8284 100644 --- a/src/features/rules/warp-rule.ts +++ b/src/features/rules/warp-rule.ts @@ -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"; @@ -10,21 +10,29 @@ import { ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, ToolRuleSettablePaths, - buildToolPath, } from "./tool-rule.js"; export type WarpRuleParams = AiFileParams & { root?: boolean; }; -export type WarpRuleSettablePaths = Omit & { +/** + * 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 & { root: { relativeDirPath: string; relativeFilePath: string; }; - nonRoot: { - relativeDirPath: string; - }; + nonRoot?: undefined; }; export class WarpRule extends ToolRule { @@ -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 { - 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, }); } @@ -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 { @@ -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, From 8bc9a4e95e3f9f86d139ad64d93c0e0b61972656 Mon Sep 17 00:00:00 2001 From: dyoshikawa Date: Mon, 15 Jun 2026 02:37:10 -0700 Subject: [PATCH 2/2] fix(warp): remove stale .warp/ gitignore leftover and tighten test assertion Address review findings on PR #1870: - Remove the orphaned `**/.warp/` line that the gitignore regenerator left in the manually-curated section. Because the entry was dropped from the registry, the cleanup pass no longer recognized it as a rulesync entry and preserved it as a user line; delete it once so regeneration stays idempotent (verified). - Replace the `toContain("**/.warp/")` assertion in gitignore.test.ts, which was a false positive matching the `**/.warp/.mcp.json` prefix, with line-wise checks that the bare rules entry is absent while the mcp/skills entries remain. --- .gitignore | 2 -- src/cli/commands/gitignore.test.ts | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 9e753f50e..42d38af33 100644 --- a/.gitignore +++ b/.gitignore @@ -221,8 +221,6 @@ mcp-schema.json docs/.vitepress/dist docs/.vitepress/cache -**/.warp/ - # Generated by Rulesync .rulesync/skills/.curated/ .rulesync/rules/*.local.md diff --git a/src/cli/commands/gitignore.test.ts b/src/cli/commands/gitignore.test.ts index b94b9c256..b83ee8e62 100644 --- a/src/cli/commands/gitignore.test.ts +++ b/src/cli/commands/gitignore.test.ts @@ -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");