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: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ rulesync.local.jsonc
**/.kiro/steering/
**/.opencode/memories/
**/.pi/APPEND_SYSTEM.md
**/AGENTS.override.md
**/QWEN.md
**/.qwen/rules/
**/REASONIX.md
Expand Down
13 changes: 12 additions & 1 deletion docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,18 @@ Multiple files can set `root: true` for the same target in project and global mo
> ---
> ```
>
> See the [Pi usage docs](https://pi.dev/docs/latest/usage).
> Pi tries `AGENTS.override.md` before `AGENTS.md`, `AGENTS.MD`, `CLAUDE.md` and `CLAUDE.MD` in every directory it scans (including the global `~/.pi/agent/` one), loading it **instead of** the others from that directory. Set `pi.contextFile: override` on the **`root: true`** rule to emit the root context file under that name — useful when another target owns the shared `AGENTS.md`, or when a `CLAUDE.md` sits next to it and Pi should deterministically prefer Rulesync's output. Because Pi folds every rule body into the root context file, one opted-in root rule decides for the whole Pi output: the flag is applied to every other Pi rule (root ones included), and setting it _only_ on a non-root rule is ignored with a warning — emitting both files would hide everything left in `AGENTS.md`. `AGENTS.override.md` is Pi-exclusive, so it is imported and deleted like the root file, and toggling the flag off cleans it up. The project-root `AGENTS.md` is never deleted on Pi's behalf, with or without the flag: `agentsmd`, `codexcli`, `warp` and others write that same path, so the `pi` target leaves a stale one behind rather than removing another target's output (the global `~/.pi/agent/AGENTS.md` is Pi-exclusive and is still cleaned up). Example:
>
> ```yaml
> ---
> root: true
> targets: ["pi"]
> pi:
> contextFile: override # emits AGENTS.override.md instead of AGENTS.md
> ---
> ```
>
> See the [Pi usage docs](https://pi.dev/docs/latest/usage) and the [context-file discovery in the Pi source](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/src/core/resource-loader.ts).

> **Devin note:** The root rule is emitted to the project-root `AGENTS.md` — the file [Devin CLI / Devin Local actually reads](https://docs.devin.ai/cli/extensibility/rules) (its rules page does not list `.devin/rules/` among its sources) — as plain markdown, while non-root rules keep going to `.devin/rules/*.md`, the Devin Desktop Cascade directory whose `trigger` activation modes (`always_on`, `glob`, `manual`, `model_decision`) are driven by the `devin` frontmatter block. Global mode is unchanged (`~/.config/devin/AGENTS.md`).

Expand Down
5 changes: 5 additions & 0 deletions src/constants/pi-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,10 @@ export const PI_EXTENSIONS_DIR_PATH = join(PI_DIR, "extensions");
export const PI_PROMPTS_DIR_PATH = join(PI_DIR, "prompts");
export const PI_SKILLS_DIR_PATH = join(PI_DIR, "skills");
export const PI_RULE_FILE_NAME = "AGENTS.md";
// Pi's context-file discovery tries `AGENTS.override.md` first in every
// directory it scans, so it wins over a sibling `AGENTS.md` or `CLAUDE.md`
// deterministically (`loadContextFileFromDir` in
// `packages/coding-agent/src/core/resource-loader.ts`).
export const PI_RULE_OVERRIDE_FILE_NAME = "AGENTS.override.md";
export const PI_APPEND_SYSTEM_FILE_NAME = "APPEND_SYSTEM.md";
export const PI_HOOKS_FILE_NAME = "rulesync-hooks.ts";
49 changes: 49 additions & 0 deletions src/e2e/e2e-rules.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,55 @@ pi:
expect(importedAppend).toContain("Pi Append Two");
});

it("should emit pi.contextFile:override as AGENTS.override.md without deleting the shared AGENTS.md", async () => {
const testDir = getTestDir();

const rootRuleContent = `---
root: true
targets: ["*"]
description: "Root rule"
globs: ["**/*"]
pi:
contextFile: override
---

# Pi Root Rule
`;
const nonRootRuleContent = `---
targets: ["pi"]
description: "Detail rule"
---

# Pi Detail Rule
`;
await writeFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME),
rootRuleContent,
);
await writeFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, "detail.md"),
nonRootRuleContent,
);

await runGenerate({ target: "agentsmd,pi", features: "rules", deleteFiles: true });

// Both pi bodies land in the override file Pi actually reads, and the shared
// AGENTS.md written by agentsmd survives pi's orphan sweep.
const overrideContent = await readFileContent(join(testDir, "AGENTS.override.md"));
expect(overrideContent).toContain("Pi Root Rule");
expect(overrideContent).toContain("Pi Detail Rule");
expect(await fileExists(join(testDir, "AGENTS.md"))).toBe(true);

// Import recovers the routing frontmatter.
await runImport({ target: "pi", features: "rules" });

const imported = await readFileContent(
join(testDir, RULESYNC_RULES_RELATIVE_DIR_PATH, RULESYNC_OVERVIEW_FILE_NAME),
);
expect(imported).toContain("contextFile: override");
expect(imported).toContain("Pi Root Rule");
});

it("should fold junie non-root rules into the root .junie/AGENTS.md", async () => {
const testDir = getTestDir();

Expand Down
97 changes: 95 additions & 2 deletions src/features/rules/pi-rule.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,17 @@ describe("PiRule", () => {
});

describe("getExtraFixedFiles", () => {
it("should enumerate the append system-prompt file (project)", () => {
it("should enumerate the append system-prompt and override context files (project)", () => {
expect(PiRule.getExtraFixedFiles()).toEqual([
{ relativeDirPath: ".pi", relativeFilePath: "APPEND_SYSTEM.md" },
{ relativeDirPath: ".", relativeFilePath: "AGENTS.override.md" },
]);
});

it("should enumerate the append system-prompt file (global)", () => {
it("should enumerate the append system-prompt and override context files (global)", () => {
expect(PiRule.getExtraFixedFiles({ global: true })).toEqual([
{ relativeDirPath: join(".pi", "agent"), relativeFilePath: "APPEND_SYSTEM.md" },
{ relativeDirPath: join(".pi", "agent"), relativeFilePath: "AGENTS.override.md" },
]);
});
});
Expand Down Expand Up @@ -428,6 +430,97 @@ describe("PiRule", () => {
});
});

describe("pi.contextFile: override", () => {
it("emits the root rule as AGENTS.override.md", () => {
const rule = PiRule.fromRulesyncRule({
outputRoot: testDir,
rulesyncRule: new RulesyncRule({
relativeDirPath: ".rulesync/rules",
relativeFilePath: "overview.md",
frontmatter: { root: true, targets: ["pi"], pi: { contextFile: "override" } },
body: "Root body.",
}),
});

expect(rule.getRelativeDirPath()).toBe(".");
expect(rule.getRelativeFilePath()).toBe("AGENTS.override.md");
expect(rule.isRoot()).toBe(true);
});

it("emits the global root rule as AGENTS.override.md", () => {
const rule = PiRule.fromRulesyncRule({
outputRoot: testDir,
global: true,
rulesyncRule: new RulesyncRule({
relativeDirPath: ".rulesync/rules",
relativeFilePath: "overview.md",
frontmatter: { root: true, targets: ["pi"], pi: { contextFile: "override" } },
body: "Root body.",
}),
});

expect(rule.getRelativeDirPath()).toBe(join(".pi", "agent"));
expect(rule.getRelativeFilePath()).toBe("AGENTS.override.md");
});

it("imports AGENTS.override.md back as the root rule carrying the flag", async () => {
const content = "# Override";
await writeFileContent(join(testDir, "AGENTS.override.md"), content);

const rule = await PiRule.fromFile({
outputRoot: testDir,
relativeFilePath: "AGENTS.override.md",
});

expect(rule.isRoot()).toBe(true);
expect(rule.getFileContent()).toBe(content);
const rulesyncRule = rule.toRulesyncRule();
expect(rulesyncRule.getFrontmatter()).toMatchObject({
root: true,
targets: ["pi"],
pi: { contextFile: "override" },
});
expect(rulesyncRule.getBody()).toBe(content);
});

it("never deletes the shared project-root AGENTS.md", () => {
const rule = PiRule.forDeletion({
outputRoot: testDir,
relativeDirPath: ".",
relativeFilePath: "AGENTS.md",
});
expect(rule.isDeletable()).toBe(false);
});

it("still deletes the pi-exclusive files", () => {
const globalRoot = PiRule.forDeletion({
outputRoot: testDir,
relativeDirPath: join(".pi", "agent"),
relativeFilePath: "AGENTS.md",
global: true,
});
const override = PiRule.forDeletion({
outputRoot: testDir,
relativeDirPath: ".",
relativeFilePath: "AGENTS.override.md",
});
expect(globalRoot.isDeletable()).toBe(true);
expect(override.isDeletable()).toBe(true);
});

it("treats both context-file names as deletable root files", () => {
for (const relativeFilePath of ["AGENTS.md", "AGENTS.override.md"]) {
const rule = PiRule.forDeletion({
outputRoot: testDir,
relativeDirPath: ".",
relativeFilePath,
});
expect(rule.isRoot()).toBe(true);
expect(rule.getRelativeFilePath()).toBe(relativeFilePath);
}
});
});

describe("isTargetedByRulesyncRule", () => {
it("should return true for pi target", () => {
const rulesyncRule = new RulesyncRule({
Expand Down
Loading
Loading