fix(agent): kept project sticky rules active

Synthesized project .omp/RULES.md as a distinct sticky rule name so capability dedup no longer shadows it behind the user sticky rule.

Added a public rules capability regression test covering user and project sticky RULES.md files loading together.

Fixes #4739
This commit is contained in:
roboomp
2026-07-06 19:43:46 +00:00
parent b086c5d669
commit 071f199a1d
3 changed files with 53 additions and 9 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed project `.omp/RULES.md` sticky rules being shadowed by user `~/.omp/agent/RULES.md` rules with the same synthesized `RULES` name, so both user and project sticky rules now inject ([#4739](https://github.com/can1357/oh-my-pi/issues/4739)).
## [16.3.11] - 2026-07-06
### Changed
@@ -401,7 +401,8 @@ async function loadStickyRulesFile(filePath: string, level: "user" | "project"):
const content = await readFile(filePath);
if (!content) return null;
const source = createSourceMeta(PROVIDER_ID, filePath, level);
const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName: "RULES" });
const ruleName = level === "project" ? "RULES@project" : "RULES";
const rule = buildRuleFromMarkdown("RULES.md", content, filePath, source, { ruleName });
// Force alwaysApply regardless of frontmatter — the whole point of RULES.md
// is to be reattached every turn.
return { ...rule, alwaysApply: true };
@@ -1,11 +1,9 @@
/**
* Regression test for #1266:
* Regression tests for top-level `RULES.md` sticky rules.
*
* `RULES.md` (singular, top-level) MUST be loaded as a sticky always-apply rule
* from both `~/.omp/agent/RULES.md` (user) and the nearest `.omp/RULES.md`
* (project, walked up from cwd to repoRoot).
*
* Calls the native provider's `load` directly with the agent dir pointed at a
* tempdir (via setAgentDir) so the user scope can be staged in isolation.
*/
import { afterEach, beforeEach, expect, test } from "bun:test";
import * as fs from "node:fs";
@@ -15,8 +13,8 @@ import { getCapability } from "@oh-my-pi/pi-coding-agent/capability";
import { clearCache } from "@oh-my-pi/pi-coding-agent/capability/fs";
import { type Rule, ruleCapability } from "@oh-my-pi/pi-coding-agent/capability/rule";
import type { LoadContext } from "@oh-my-pi/pi-coding-agent/capability/types";
// Register all discovery providers as a side effect.
import "@oh-my-pi/pi-coding-agent/discovery";
// Importing discovery registers all providers as a side effect.
import { loadCapability } from "@oh-my-pi/pi-coding-agent/discovery";
import { getConfigRootDir, removeSyncWithRetries, setAgentDir } from "@oh-my-pi/pi-utils";
let tempDir: string;
@@ -40,6 +38,11 @@ async function loadNativeRules(ctx: LoadContext): Promise<Rule[]> {
return result.items;
}
async function loadRulesCapability(cwd: string): Promise<Rule[]> {
const result = await loadCapability<Rule>(ruleCapability.id, { cwd, providers: ["native"] });
return result.items;
}
beforeEach(() => {
clearCache();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-rules-md-"));
@@ -81,7 +84,7 @@ test("project .omp/RULES.md becomes an alwaysApply rule", async () => {
const rules = await loadNativeRules({ cwd: project, home, repoRoot: project });
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES");
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES@project");
expect(projectRule).toBeDefined();
expect(projectRule?.alwaysApply).toBe(true);
expect(projectRule?.content).toContain("Always say hi.");
@@ -94,12 +97,48 @@ test("project RULES.md is found walking up from a sub-package cwd", async () =>
const rules = await loadNativeRules({ cwd: subPkg, home, repoRoot: project });
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES");
const projectRule = rules.find(r => r._source.level === "project" && r.name === "RULES@project");
expect(projectRule).toBeDefined();
expect(projectRule?.alwaysApply).toBe(true);
expect(projectRule?.path).toBe(path.join(project, ".omp", "RULES.md"));
});
test("user and project sticky RULES.md both survive public capability dedup", async () => {
const userRulesPath = path.join(home, ".omp", "agent", "RULES.md");
const projectRulesPath = path.join(project, ".omp", "RULES.md");
const userRuleText = "User sticky rule: keep the personal safety checklist active.\n";
const projectRuleText = "Project sticky rule: require repo-local release notes.\n";
writeFile(userRulesPath, userRuleText);
writeFile(projectRulesPath, projectRuleText);
const rules = await loadRulesCapability(project);
const stickyRules = rules.filter(rule => rule.path === userRulesPath || rule.path === projectRulesPath);
expect(stickyRules).toHaveLength(2);
const userRule = stickyRules.find(rule => rule._source.level === "user");
const projectRule = stickyRules.find(rule => rule._source.level === "project");
if (!userRule) throw new Error("user sticky rule missing");
expect(userRule.name).toBe("RULES");
expect(userRule.path).toBe(userRulesPath);
expect(userRule._source.path).toBe(userRulesPath);
expect(userRule.alwaysApply).toBe(true);
expect(userRule.content).toContain(userRuleText.trim());
expect("_shadowed" in userRule).toBe(false);
if (!projectRule) throw new Error("project sticky rule missing");
expect(projectRule.name).toBe("RULES@project");
expect(projectRule.path).toBe(projectRulesPath);
expect(projectRule._source.path).toBe(projectRulesPath);
expect(projectRule.alwaysApply).toBe(true);
expect(projectRule.content).toContain(projectRuleText.trim());
expect("_shadowed" in projectRule).toBe(false);
expect(userRule.name).not.toBe(projectRule.name);
expect(userRule.content).not.toBe(projectRule.content);
});
test("alwaysApply is forced even when frontmatter says false", async () => {
writeFile(path.join(home, ".omp", "agent", "RULES.md"), "---\nalwaysApply: false\n---\nStick around anyway.\n");