diff --git a/.gitignore b/.gitignore index b875b1a58..7c7ea8919 100644 --- a/.gitignore +++ b/.gitignore @@ -221,6 +221,8 @@ mcp-schema.json docs/.vitepress/dist docs/.vitepress/cache +**/.codex/memories/ + # Generated by Rulesync .rulesync/skills/.curated/ .rulesync/rules/*.local.md @@ -254,7 +256,6 @@ rulesync.local.jsonc **/.claude/CLAUDE.md **/.claude/rules/ **/.clinerules/ -**/.codex/memories/ **/.github/copilot-instructions.md **/.github/instructions/ **/.cursor/rules/ diff --git a/src/cli/commands/gitignore-entries.test.ts b/src/cli/commands/gitignore-entries.test.ts index b9286fab8..09140ba25 100644 --- a/src/cli/commands/gitignore-entries.test.ts +++ b/src/cli/commands/gitignore-entries.test.ts @@ -231,7 +231,10 @@ describe("filterGitignoreEntries", () => { // Should include general entries for all targets expect(result).toContain("**/.claude/memories/"); - expect(result).toContain("**/.codex/memories/"); + + // codexcli no longer emits .codex/memories/ (non-root rules are folded + // into the root AGENTS.md — see issue #1765) + expect(result).not.toContain("**/.codex/memories/"); // Should include rules entries expect(result).toContain("**/CLAUDE.md"); diff --git a/src/cli/commands/gitignore.test.ts b/src/cli/commands/gitignore.test.ts index e0c1ae484..1f069f8d4 100644 --- a/src/cli/commands/gitignore.test.ts +++ b/src/cli/commands/gitignore.test.ts @@ -94,7 +94,9 @@ describe("gitignoreCommand", () => { // `.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/"); + // codexcli no longer emits .codex/memories/ (non-root rules are folded + // into the root AGENTS.md — see issue #1765) + expect(content).not.toContain("**/.codex/memories/"); expect(content).toContain("**/.agents/skills/"); expect(content).toContain("**/.deepagents/AGENTS.md"); expect(content).not.toContain("**/.deepagents/memories/"); diff --git a/src/features/rules/codexcli-rule.test.ts b/src/features/rules/codexcli-rule.test.ts index 259a74aa3..86d3f1e21 100644 --- a/src/features/rules/codexcli-rule.test.ts +++ b/src/features/rules/codexcli-rule.test.ts @@ -48,29 +48,21 @@ This is the main agent configuration for the project. expect(rule.getOutputRoot()).toBe(testDir); }); - it("should load non-root rule from .codex/memories directory", async () => { - const memoriesDir = join(testDir, ".codex", "memories"); - await ensureDir(memoriesDir); + it("should read the root AGENTS.md even when given a non-root relativeFilePath", async () => { + const content = `# Root Agent Configuration - const memoryContent = `# Memory Instructions - -This is a specific memory configuration. - -- Handle errors gracefully -- Log important events`; - - const filePath = join(memoriesDir, "error-handling.md"); - await writeFileContent(filePath, memoryContent); +This is the single source of truth for Codex CLI.`; + await writeFileContent(join(testDir, "AGENTS.md"), content); const rule = await CodexcliRule.fromFile({ outputRoot: testDir, relativeFilePath: "error-handling.md", }); - expect(rule.getFileContent()).toBe(memoryContent); - expect(rule.getRelativeFilePath()).toBe("error-handling.md"); - expect(rule.getRelativeDirPath()).toBe(".codex/memories"); - expect(rule.getOutputRoot()).toBe(testDir); + expect(rule.getFileContent()).toBe(content); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(rule.isRoot()).toBe(true); }); it("should handle empty content files", async () => { @@ -124,8 +116,7 @@ This is a specific memory configuration. expect(ruleWithoutValidation.getFileContent()).toBe(agentsContent); }); - it("should determine root status correctly", async () => { - // Test root file + it("should always read from the root AGENTS.md", async () => { const rootContent = "Root agent instructions"; const rootPath = join(testDir, "AGENTS.md"); await writeFileContent(rootPath, rootContent); @@ -137,22 +128,16 @@ This is a specific memory configuration. expect(rootRule.getRelativeFilePath()).toBe("AGENTS.md"); expect(rootRule.getRelativeDirPath()).toBe("."); - - // Test non-root file - const memoriesDir = join(testDir, ".codex", "memories"); - await ensureDir(memoriesDir); - - const nonRootContent = "Non-root memory instructions"; - const nonRootPath = join(memoriesDir, "specific.md"); - await writeFileContent(nonRootPath, nonRootContent); + expect(rootRule.isRoot()).toBe(true); const nonRootRule = await CodexcliRule.fromFile({ outputRoot: testDir, relativeFilePath: "specific.md", }); - expect(nonRootRule.getRelativeFilePath()).toBe("specific.md"); - expect(nonRootRule.getRelativeDirPath()).toBe(".codex/memories"); + expect(nonRootRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(nonRootRule.getRelativeDirPath()).toBe("."); + expect(nonRootRule.isRoot()).toBe(true); }); }); @@ -194,9 +179,10 @@ This is a specific memory configuration. }); expect(codexcliRule.getFileContent()).toBe("Non-root rule body content"); - expect(codexcliRule.getRelativeFilePath()).toBe("specific.md"); - expect(codexcliRule.getRelativeDirPath()).toBe(".codex/memories"); + expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(codexcliRule.getRelativeDirPath()).toBe("."); expect(codexcliRule.getOutputRoot()).toBe(testDir); + expect(codexcliRule.isRoot()).toBe(false); }); it("should handle empty body content", () => { @@ -254,7 +240,7 @@ More detailed instructions here.`; expect(codexcliRule.getFileContent()).toBe(complexBody); }); - it("should handle subprojectPath from agentsmd field", () => { + it("should ignore subprojectPath and target root AGENTS.md (folding)", () => { const rulesyncRule = new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, @@ -277,7 +263,7 @@ More detailed instructions here.`; expect(codexcliRule.getFileContent()).toBe( "# Subproject CodexCLI\n\nContent for subproject.", ); - expect(codexcliRule.getRelativeDirPath()).toBe("packages/my-app"); + expect(codexcliRule.getRelativeDirPath()).toBe("."); expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); }); @@ -290,7 +276,7 @@ More detailed instructions here.`; root: true, targets: ["codexcli"], agentsmd: { - subprojectPath: "packages/my-app", // Should be ignored + subprojectPath: "packages/my-app", }, }, body: "# Root CodexCLI\n\nRoot content.", @@ -306,7 +292,7 @@ More detailed instructions here.`; expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); }); - it("should handle empty subprojectPath", () => { + it("should target root AGENTS.md even with empty subprojectPath", () => { const rulesyncRule = new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, @@ -327,11 +313,11 @@ More detailed instructions here.`; }); expect(codexcliRule.getFileContent()).toBe("# Empty Subproject CodexCLI\n\nContent."); - expect(codexcliRule.getRelativeDirPath()).toBe(".codex/memories"); - expect(codexcliRule.getRelativeFilePath()).toBe("test.md"); + expect(codexcliRule.getRelativeDirPath()).toBe("."); + expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); }); - it("should handle complex nested subprojectPath", () => { + it("should target root AGENTS.md even with complex nested subprojectPath", () => { const rulesyncRule = new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, @@ -354,11 +340,11 @@ More detailed instructions here.`; expect(codexcliRule.getFileContent()).toBe( "# Nested Subproject CodexCLI\n\nDeeply nested content.", ); - expect(codexcliRule.getRelativeDirPath()).toBe("packages/apps/my-app/src"); + expect(codexcliRule.getRelativeDirPath()).toBe("."); expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); }); - it("should handle undefined agentsmd field", () => { + it("should target root AGENTS.md when agentsmd field is undefined", () => { const rulesyncRule = new RulesyncRule({ outputRoot: testDir, relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, @@ -376,8 +362,8 @@ More detailed instructions here.`; }); expect(codexcliRule.getFileContent()).toBe("# No agentsmd\n\nContent without agentsmd."); - expect(codexcliRule.getRelativeDirPath()).toBe(".codex/memories"); - expect(codexcliRule.getRelativeFilePath()).toBe("test.md"); + expect(codexcliRule.getRelativeDirPath()).toBe("."); + expect(codexcliRule.getRelativeFilePath()).toBe("AGENTS.md"); }); it("should respect validation parameter", () => { @@ -447,25 +433,26 @@ More detailed instructions here.`; expect(rulesyncRule.getFrontmatter().globs).toEqual(["**/*"]); }); - it("should convert non-root CodexcliRule to RulesyncRule", async () => { - const memoriesDir = join(testDir, ".codex", "memories"); - await ensureDir(memoriesDir); - - const memoryContent = "Non-root memory instructions"; - const filePath = join(memoriesDir, "specific.md"); - await writeFileContent(filePath, memoryContent); - - const codexcliRule = await CodexcliRule.fromFile({ + it("should convert non-root CodexcliRule to RulesyncRule", () => { + const rulesyncRuleInput = new RulesyncRule({ outputRoot: testDir, + relativeDirPath: "rules", relativeFilePath: "specific.md", + frontmatter: { root: false, targets: ["*"], description: "Test rule", globs: [] }, + body: "Non-root rule body content", + validate: false, + }); + + const codexcliRule = CodexcliRule.fromRulesyncRule({ + outputRoot: testDir, + rulesyncRule: rulesyncRuleInput, }); const rulesyncRule = codexcliRule.toRulesyncRule(); - expect(rulesyncRule.getBody()).toBe(memoryContent); + expect(rulesyncRule.getBody()).toBe("Non-root rule body content"); expect(rulesyncRule.getFrontmatter().root).toBe(false); expect(rulesyncRule.getFrontmatter().targets).toEqual(["*"]); - expect(rulesyncRule.getFrontmatter().description).toBeUndefined(); expect(rulesyncRule.getFrontmatter().globs).toEqual([]); }); @@ -570,7 +557,7 @@ More detailed instructions here.`; }); describe("getSettablePaths", () => { - it("should return correct paths for root and nonRoot", () => { + it("should return only the root AGENTS.md path (no nonRoot)", () => { const paths = CodexcliRule.getSettablePaths(); expect(paths.root).toEqual({ @@ -578,19 +565,15 @@ More detailed instructions here.`; relativeFilePath: "AGENTS.md", }); - expect(paths.nonRoot).toEqual({ - relativeDirPath: ".codex/memories", - }); + expect(paths.nonRoot).toBeUndefined(); }); it("should have consistent paths structure", () => { const paths = CodexcliRule.getSettablePaths(); expect(paths).toHaveProperty("root"); - expect(paths).toHaveProperty("nonRoot"); expect(paths.root).toHaveProperty("relativeDirPath"); expect(paths.root).toHaveProperty("relativeFilePath"); - expect(paths.nonRoot).toHaveProperty("relativeDirPath"); }); }); @@ -873,64 +856,28 @@ interface ApiResponse { expect(finalRulesyncRule.getBody()).toBe(originalContent); expect(finalRulesyncRule.getFrontmatter().root).toBe(true); expect(finalRulesyncRule.getFrontmatter().targets).toEqual(["*"]); - expect(finalRulesyncRule.getFrontmatter().description).toBe("OpenAI Codex CLI configuration"); - expect(finalRulesyncRule.getFrontmatter().globs).toEqual(["src/**/*.ts"]); expect(finalRulesyncRule.validate().success).toBe(true); }); it("should handle file system operations correctly", async () => { - // Setup directory structure - const memoriesDir = join(testDir, ".codex", "memories"); - await ensureDir(memoriesDir); - - // Test multiple files - const files = [ - { path: "AGENTS.md", content: "Main agent instructions", isRoot: true }, - { - path: join("memories", "typescript.md"), - content: "TypeScript guidelines", - isRoot: false, - }, - { path: join("memories", "testing.md"), content: "Testing best practices", isRoot: false }, - ]; - - const rules: CodexcliRule[] = []; - - // Create and load all files - for (const file of files) { - const fullPath = file.isRoot - ? join(testDir, file.path) - : join(testDir, ".codex", file.path); - await writeFileContent(fullPath, file.content); + // CodexcliRule always reads from the root AGENTS.md + const rootContent = "Main agent instructions"; + await writeFileContent(join(testDir, "AGENTS.md"), rootContent); - const fileName = file.isRoot ? file.path : file.path.split("/").pop()!; - const rule = await CodexcliRule.fromFile({ - outputRoot: testDir, - relativeFilePath: fileName, - }); - - expect(rule.getFileContent()).toBe(file.content); - expect(rule.validate().success).toBe(true); - - rules.push(rule); - } - - // Verify each rule has correct properties - expect(rules[0]?.getRelativeFilePath()).toBe("AGENTS.md"); - expect(rules[0]?.getRelativeDirPath()).toBe("."); - - expect(rules[1]?.getRelativeFilePath()).toBe("typescript.md"); - expect(rules[1]?.getRelativeDirPath()).toBe(".codex/memories"); + const rule = await CodexcliRule.fromFile({ + outputRoot: testDir, + relativeFilePath: "AGENTS.md", + }); - expect(rules[2]?.getRelativeFilePath()).toBe("testing.md"); - expect(rules[2]?.getRelativeDirPath()).toBe(".codex/memories"); + expect(rule.getFileContent()).toBe(rootContent); + expect(rule.getRelativeFilePath()).toBe("AGENTS.md"); + expect(rule.getRelativeDirPath()).toBe("."); + expect(rule.validate().success).toBe(true); - // Test conversion to RulesyncRule for all - for (const rule of rules) { - const rulesyncRule = rule.toRulesyncRule(); - expect(rulesyncRule.validate().success).toBe(true); - expect(rulesyncRule.getBody()).toBe(rule.getFileContent()); - } + // toRulesyncRule round-trip + const rulesyncRule = rule.toRulesyncRule(); + expect(rulesyncRule.validate().success).toBe(true); + expect(rulesyncRule.getBody()).toBe(rootContent); }); }); }); diff --git a/src/features/rules/codexcli-rule.ts b/src/features/rules/codexcli-rule.ts index a7d0d94e5..915c685c8 100644 --- a/src/features/rules/codexcli-rule.ts +++ b/src/features/rules/codexcli-rule.ts @@ -1,7 +1,7 @@ import { join } from "node:path"; import { CODEXCLI_DIR, CODEXCLI_RULE_FILE_NAME } from "../../constants/codexcli-paths.js"; -import { ValidationResult } from "../../types/ai-file.js"; +import { AiFileParams, ValidationResult } from "../../types/ai-file.js"; import { readFileContent } from "../../utils/file.js"; import { RulesyncRule } from "./rulesync-rule.js"; import { @@ -10,91 +10,76 @@ import { ToolRuleFromFileParams, ToolRuleFromRulesyncRuleParams, ToolRuleSettablePaths, - ToolRuleSettablePathsGlobal, - buildToolPath, } from "./tool-rule.js"; -export type CodexcliRuleSettablePaths = ToolRuleSettablePaths & { +export type CodexcliRuleParams = AiFileParams & { + root?: boolean; +}; + +/** + * Rule generator for OpenAI Codex CLI. + * + * Codex CLI loads project instructions only from the `AGENTS.md` family — the + * global `~/.codex/AGENTS.md`, then hierarchical `AGENTS.md` / `AGENTS.override.md` + * files discovered by walking from the project root to the current working + * directory. It does NOT scan a `.codex/memories/` directory for instruction + * files — that directory belongs to Codex's separate SQLite-backed auto-memory + * system. (Verified against the official docs: + * https://developers.openai.com/codex/guides/agents-md) + * + * 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`). This mirrors the grokcli, warp, and deepagents targets. + */ +export type CodexcliRuleSettablePaths = Pick & { root: { relativeDirPath: string; relativeFilePath: string; }; + nonRoot?: undefined; }; -export type CodexcliRuleSettablePathsGlobal = ToolRuleSettablePathsGlobal; - -/** - * Rule generator for OpenAI Codex CLI - * - * Generates AGENTS.md files based on rulesync rule content. - * Supports the OpenAI Codex CLI memory/instructions system with - * hierarchical loading (global, project, directory-specific). - */ export class CodexcliRule extends ToolRule { + constructor({ fileContent, root, ...rest }: CodexcliRuleParams) { + super({ + ...rest, + fileContent, + root: root ?? false, + }); + } + static getSettablePaths({ - global, - excludeToolDir, + global = false, }: { global?: boolean; excludeToolDir?: boolean; - } = {}): CodexcliRuleSettablePaths | CodexcliRuleSettablePathsGlobal { - if (global) { - return { - root: { - relativeDirPath: buildToolPath(CODEXCLI_DIR, ".", excludeToolDir), - relativeFilePath: CODEXCLI_RULE_FILE_NAME, - }, - }; - } + } = {}): CodexcliRuleSettablePaths { return { root: { - relativeDirPath: ".", + relativeDirPath: global ? CODEXCLI_DIR : ".", relativeFilePath: CODEXCLI_RULE_FILE_NAME, }, - nonRoot: { - relativeDirPath: buildToolPath(CODEXCLI_DIR, "memories", excludeToolDir), - }, }; } static async fromFile({ outputRoot = process.cwd(), - relativeFilePath, + relativeFilePath: _relativeFilePath, validate = true, global = false, }: ToolRuleFromFileParams): Promise { - const paths = this.getSettablePaths({ global }); - const isRoot = relativeFilePath === paths.root.relativeFilePath; - - if (isRoot) { - const relativePath = paths.root.relativeFilePath; - const fileContent = await readFileContent( - join(outputRoot, paths.root.relativeDirPath, relativePath), - ); - - return new CodexcliRule({ - outputRoot, - relativeDirPath: paths.root.relativeDirPath, - relativeFilePath: paths.root.relativeFilePath, - fileContent, - validate, - root: true, - }); - } - - if (!paths.nonRoot) { - throw new Error(`nonRoot path is not set for ${relativeFilePath}`); - } - - const relativePath = join(paths.nonRoot.relativeDirPath, relativeFilePath); + const { root } = this.getSettablePaths({ global }); + const relativePath = join(root.relativeDirPath, root.relativeFilePath); const fileContent = await readFileContent(join(outputRoot, relativePath)); + return new CodexcliRule({ outputRoot, - relativeDirPath: paths.nonRoot.relativeDirPath, - relativeFilePath: relativeFilePath, + relativeDirPath: root.relativeDirPath, + relativeFilePath: root.relativeFilePath, fileContent, validate, - root: false, + root: true, }); } @@ -104,16 +89,17 @@ export class CodexcliRule extends ToolRule { validate = true, global = false, }: ToolRuleFromRulesyncRuleParams): CodexcliRule { - const paths = this.getSettablePaths({ global }); - return new CodexcliRule( - this.buildToolRuleParamsAgentsmd({ - outputRoot, - rulesyncRule, - validate, - rootPath: paths.root, - nonRootPath: paths.nonRoot, - }), - ); + const { root } = this.getSettablePaths({ global }); + const isRoot = rulesyncRule.getFrontmatter().root ?? false; + + return new CodexcliRule({ + outputRoot, + relativeDirPath: root.relativeDirPath, + relativeFilePath: root.relativeFilePath, + fileContent: rulesyncRule.getBody(), + validate, + root: isRoot, + }); } toRulesyncRule(): RulesyncRule { @@ -121,9 +107,6 @@ export class CodexcliRule extends ToolRule { } validate(): ValidationResult { - // OpenAI Codex CLI rules are always valid since they don't have complex frontmatter - // The body content can be empty (though not recommended in practice) - // This follows the same pattern as other rule validation methods return { success: true, error: null }; } @@ -131,10 +114,10 @@ export class CodexcliRule extends ToolRule { outputRoot = process.cwd(), relativeDirPath, relativeFilePath, - global = false, }: ToolRuleForDeletionParams): CodexcliRule { - const paths = this.getSettablePaths({ global }); - const isRoot = relativeFilePath === paths.root.relativeFilePath; + const isRoot = + relativeFilePath === CODEXCLI_RULE_FILE_NAME && + (relativeDirPath === "." || relativeDirPath === CODEXCLI_DIR); return new CodexcliRule({ outputRoot, diff --git a/src/features/rules/rules-processor.ts b/src/features/rules/rules-processor.ts index 159ba79ce..d1c77dabf 100644 --- a/src/features/rules/rules-processor.ts +++ b/src/features/rules/rules-processor.ts @@ -382,7 +382,8 @@ export const toolRuleFactories = new Map