feat(omo-codex): add PLUGIN_BUNDLED rule source to codex-rules engine

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-27 14:54:20 +09:00
parent db00bb3d42
commit 8d2e6bea62
12 changed files with 688 additions and 181 deletions
@@ -2,10 +2,9 @@ import { SOURCE_PRIORITY } from "./rules/constants.js";
import { defaultConfig } from "./rules/engine.js";
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
const MODE_VALUES = new Set<PiRulesConfig["mode"]>(["static", "dynamic", "both", "off"]);
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
const config = defaultConfig();
const disableBundledRules = isTruthy(firstEnv(env, "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED"));
config.disabled = isTruthy(firstEnv(env, "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
config.mode = parseMode(firstEnv(env, "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
config.maxRuleChars =
@@ -16,6 +15,7 @@ export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiR
config.maxResultChars;
config.enabledSources = parseEnabledSources(
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
disableBundledRules,
);
return config;
}
@@ -38,7 +38,15 @@ function isTruthy(value: string | undefined): boolean {
function parseMode(value: string | undefined): PiRulesConfig["mode"] | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
return MODE_VALUES.has(normalized as PiRulesConfig["mode"]) ? (normalized as PiRulesConfig["mode"]) : undefined;
switch (normalized) {
case "static":
case "dynamic":
case "both":
case "off":
return normalized;
default:
return undefined;
}
}
function parsePositiveInteger(value: string | undefined): number | undefined {
@@ -47,19 +55,45 @@ function parsePositiveInteger(value: string | undefined): number | undefined {
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" {
function parseEnabledSources(value: string | undefined, disableBundledRules: boolean): RuleSource[] | "auto" {
if (value === undefined || value.trim().toLowerCase() === "auto") {
return "auto";
return disableBundledRules ? sourcesWithoutBundledRules() : "auto";
}
const validSources = new Set(SOURCE_PRIORITY.keys());
const sources: RuleSource[] = [];
for (const rawSource of value.split(",")) {
const source = rawSource.trim();
if (!validSources.has(source as RuleSource)) {
const source = toRuleSource(rawSource.trim());
if (source === null) {
continue;
}
sources.push(source as RuleSource);
sources.push(source);
}
const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources;
return enabledSources.length > 0 || sources.length > 0 ? enabledSources : "auto";
}
function sourcesWithoutBundledRules(): RuleSource[] {
return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled");
}
function toRuleSource(value: string): RuleSource | null {
switch (value) {
case ".omo/rules":
case ".claude/rules":
case ".cursor/rules":
case ".github/instructions":
case ".github/copilot-instructions.md":
case "AGENTS.md":
case "CLAUDE.md":
case "CONTEXT.md":
case "plugin-bundled":
case "~/.omo/rules":
case "~/.opencode/rules":
case "~/.claude/rules":
case "~/.config/opencode/AGENTS.md":
case "~/.claude/CLAUDE.md":
return value;
default:
return null;
}
return sources.length > 0 ? sources : "auto";
}
@@ -45,6 +45,11 @@ export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".openco
*/
export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"];
/**
* Bundled plugin rule directory relative to the rules component root.
*/
export const BUNDLED_RULE_SUBDIR = "bundled-rules";
/**
* File extensions accepted as rule files in scanned directories.
*/
@@ -67,6 +72,7 @@ export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
["~/.claude/rules", 102],
["~/.config/opencode/AGENTS.md", 103],
["~/.claude/CLAUDE.md", 104],
["plugin-bundled", 200],
]);
/**
@@ -0,0 +1,73 @@
import { existsSync, realpathSync, statSync } from "node:fs";
import { scanRuleFiles } from "./scanner.js";
type ScannedRuleFiles = ReturnType<typeof scanRuleFiles>;
interface SingleFileInfo {
readonly path: string;
readonly realPath: string;
}
export interface RuleDiscoveryCache {
readonly scannedRuleFiles: Map<string, ScannedRuleFiles>;
readonly singleFileInfo: Map<string, SingleFileInfo | null>;
}
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
}
export function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles {
if (cache === undefined) {
return scanRuleFiles({ rootDir });
}
const cached = cache.scannedRuleFiles.get(rootDir);
if (cached !== undefined) {
return cached;
}
const scannedFiles = scanRuleFiles({ rootDir });
cache.scannedRuleFiles.set(rootDir, scannedFiles);
return scannedFiles;
}
export function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
if (cache === undefined) {
return readSingleFileInfo(filePath);
}
const cached = cache.singleFileInfo.get(filePath);
if (cached !== undefined) {
return cached;
}
const fileInfo = readSingleFileInfo(filePath);
cache.singleFileInfo.set(filePath, fileInfo);
return fileInfo;
}
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
if (!existsSync(filePath)) {
return null;
}
try {
if (!statSync(filePath).isFile()) {
return null;
}
return { path: filePath, realPath: resolveRealPath(filePath) };
} catch {
return null;
}
}
function resolveRealPath(filePath: string): string {
try {
return realpathSync.native(filePath);
} catch {
return filePath;
}
}
@@ -0,0 +1,47 @@
import { dirname, posix, relative, resolve } from "node:path";
export interface WalkDirectory {
readonly directory: string;
readonly distance: number;
}
export function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
if (targetFile === null) {
return [{ directory: projectRoot, distance: 0 }];
}
const startDirectory = dirname(resolve(targetFile));
if (!isSameOrChildPath(startDirectory, projectRoot)) {
return [{ directory: projectRoot, distance: 0 }];
}
const walkDirectories: WalkDirectory[] = [];
let currentDirectory = startDirectory;
let distance = 0;
while (true) {
walkDirectories.push({ directory: currentDirectory, distance });
if (currentDirectory === projectRoot) {
break;
}
const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
break;
}
currentDirectory = parentDirectory;
distance += 1;
}
return walkDirectories;
}
export function toRelativePath(rootDirectory: string, filePath: string): string {
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, childPath);
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
}
@@ -0,0 +1,50 @@
import { UnsupportedRuleSourceError } from "./errors.js";
import type { RuleSource } from "./types.js";
export function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
const source = `${parentDirectory}/${subDirectory}`;
switch (source) {
case ".omo/rules":
case ".claude/rules":
case ".cursor/rules":
case ".github/instructions":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
}
}
export function toProjectSingleFileSource(ruleFile: string): RuleSource {
switch (ruleFile) {
case ".github/copilot-instructions.md":
case "AGENTS.md":
case "CLAUDE.md":
case "CONTEXT.md":
return ruleFile;
default:
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
}
}
export function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
const source = `~/${ruleSubdir}`;
switch (source) {
case "~/.omo/rules":
case "~/.opencode/rules":
case "~/.claude/rules":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
}
}
export function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
const source = `~/${ruleFile}`;
switch (source) {
case "~/.config/opencode/AGENTS.md":
case "~/.claude/CLAUDE.md":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
}
}
@@ -1,27 +1,27 @@
import { existsSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, posix, relative, resolve } from "node:path";
import { join, resolve } from "node:path";
import {
GLOBAL_DISTANCE,
BUNDLED_RULE_SUBDIR,
PROJECT_RULE_SUBDIRS,
PROJECT_SINGLE_FILES,
USER_HOME_RULE_SUBDIRS,
USER_HOME_SINGLE_FILES,
} from "./constants.js";
import { UnsupportedRuleSourceError } from "./errors.js";
import { scanRuleFiles } from "./scanner.js";
import type { RuleCandidate, RuleSource } from "./types.js";
import { type RuleDiscoveryCache, scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js";
import { getWalkDirectories, toRelativePath } from "./finder-paths.js";
import {
toProjectRuleSource,
toProjectSingleFileSource,
toUserHomeRuleSource,
toUserHomeSingleFileSource,
} from "./finder-sources.js";
import { resolvePluginRulesRoot } from "./plugin-root.js";
import type { RuleCandidate } from "./types.js";
interface SingleFileInfo {
path: string;
realPath: string;
}
export interface RuleDiscoveryCache {
scannedRuleFiles: Map<string, ReturnType<typeof scanRuleFiles>>;
singleFileInfo: Map<string, SingleFileInfo | null>;
}
export type { RuleDiscoveryCache } from "./finder-cache.js";
export { createRuleDiscoveryCache } from "./finder-cache.js";
export interface FinderOptions {
/** Project root absolute path (use findProjectRoot to get this). */
@@ -34,24 +34,19 @@ export interface FinderOptions {
disabledSources?: ReadonlySet<string>;
/** Whether to skip user-home rules. Default: false. */
skipUserHome?: boolean;
/** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */
pluginRoot?: string;
cache?: RuleDiscoveryCache;
}
interface WalkDirectory {
directory: string;
distance: number;
}
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
interface PluginBundledFinderOptions {
readonly disabledSources?: ReadonlySet<string>;
readonly cache?: RuleDiscoveryCache;
readonly pluginRoot?: string;
}
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
const skipUserHome = options.skipUserHome ?? false;
if (options.projectRoot === null && skipUserHome) {
return [];
}
const disabledSources = options.disabledSources ?? new Set<string>();
const candidates: RuleCandidate[] = [];
const homeDirectory = resolve(options.homeDir ?? homedir());
@@ -62,6 +57,13 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
);
}
const pluginBundledOptions: PluginBundledFinderOptions = {
disabledSources,
...(options.cache === undefined ? {} : { cache: options.cache }),
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
};
candidates.push(...findPluginBundledCandidates(pluginBundledOptions));
if (!skipUserHome) {
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
}
@@ -69,6 +71,28 @@ export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
return candidates;
}
export function findPluginBundledCandidates(options: PluginBundledFinderOptions = {}): RuleCandidate[] {
if (options.disabledSources?.has("plugin-bundled") === true) {
return [];
}
const pluginRoot = resolvePluginRulesRoot(options.pluginRoot);
const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR);
const candidates: RuleCandidate[] = [];
for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) {
candidates.push({
path: scannedFile.path,
realPath: scannedFile.realPath,
source: "plugin-bundled",
distance: GLOBAL_DISTANCE,
isGlobal: true,
isSingleFile: false,
relativePath: toRelativePath(pluginRoot, scannedFile.path),
});
}
return candidates;
}
function findProjectCandidates(
projectRoot: string,
targetFile: string | null,
@@ -181,146 +205,3 @@ function findUserHomeCandidates(
return candidates;
}
function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ReturnType<typeof scanRuleFiles> {
if (cache === undefined) {
return scanRuleFiles({ rootDir });
}
const cached = cache.scannedRuleFiles.get(rootDir);
if (cached !== undefined) {
return cached;
}
const scannedFiles = scanRuleFiles({ rootDir });
cache.scannedRuleFiles.set(rootDir, scannedFiles);
return scannedFiles;
}
function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
if (cache === undefined) {
return readSingleFileInfo(filePath);
}
const cached = cache.singleFileInfo.get(filePath);
if (cached !== undefined) {
return cached;
}
const fileInfo = readSingleFileInfo(filePath);
cache.singleFileInfo.set(filePath, fileInfo);
return fileInfo;
}
function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
if (targetFile === null) {
return [{ directory: projectRoot, distance: 0 }];
}
const startDirectory = dirname(resolve(targetFile));
if (!isSameOrChildPath(startDirectory, projectRoot)) {
return [{ directory: projectRoot, distance: 0 }];
}
const walkDirectories: WalkDirectory[] = [];
let currentDirectory = startDirectory;
let distance = 0;
while (true) {
walkDirectories.push({ directory: currentDirectory, distance });
if (currentDirectory === projectRoot) {
break;
}
const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
break;
}
currentDirectory = parentDirectory;
distance += 1;
}
return walkDirectories;
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, childPath);
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
}
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
if (!existsSync(filePath)) {
return null;
}
try {
if (!statSync(filePath).isFile()) {
return null;
}
return { path: filePath, realPath: resolveRealPath(filePath) };
} catch {
return null;
}
}
function resolveRealPath(filePath: string): string {
try {
return realpathSync.native(filePath);
} catch {
return filePath;
}
}
function toRelativePath(rootDirectory: string, filePath: string): string {
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
}
function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
const source = `${parentDirectory}/${subDirectory}`;
switch (source) {
case ".omo/rules":
case ".claude/rules":
case ".cursor/rules":
case ".github/instructions":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
}
}
function toProjectSingleFileSource(ruleFile: string): RuleSource {
switch (ruleFile) {
case ".github/copilot-instructions.md":
case "AGENTS.md":
case "CLAUDE.md":
case "CONTEXT.md":
return ruleFile;
default:
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
}
}
function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
const source = `~/${ruleSubdir}`;
switch (source) {
case "~/.omo/rules":
case "~/.opencode/rules":
case "~/.claude/rules":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
}
}
function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
const source = `~/${ruleFile}`;
switch (source) {
case "~/.config/opencode/AGENTS.md":
case "~/.claude/CLAUDE.md":
return source;
default:
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
}
}
@@ -50,7 +50,39 @@ export function formatStaticBlock(rules: ReadonlyArray<LoadedRule>, options: For
return "";
}
return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`;
return `\n\n## Project Instructions\n${truncateRules(staticDisplayRules(rules), options).map(formatRule).join("\n\n")}`;
}
function staticDisplayRules(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
const uniqueRules = uniqueRulesByBody(rules);
return [
...uniqueRules.filter((rule) => rule.source === "plugin-bundled"),
...uniqueRules.filter((rule) => rule.source !== "plugin-bundled"),
];
}
function uniqueRulesByBody(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
const uniqueRules: LoadedRule[] = [];
const seenBodies = new Set<string>();
const userDescriptions = new Set<string>();
for (const rule of rules) {
const descriptionKey = rule.frontmatter.description?.trim();
if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) {
continue;
}
const bodyKey = rule.body.trim();
if (seenBodies.has(bodyKey)) {
continue;
}
seenBodies.add(bodyKey);
if (descriptionKey !== undefined && rule.source !== "plugin-bundled") {
userDescriptions.add(descriptionKey);
}
uniqueRules.push(rule);
}
return uniqueRules;
}
export function formatDynamicBlock(
@@ -0,0 +1,55 @@
import { statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const PLUGIN_MANIFEST_PATH = join(".codex-plugin", "plugin.json");
export function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl = import.meta.url): string {
const configuredRoot = pluginRoot ?? process.env["PLUGIN_ROOT"];
if (configuredRoot !== undefined && configuredRoot.trim().length > 0) {
return resolveRulesComponentRoot(resolve(configuredRoot));
}
const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl)));
if (discoveredRoot !== null) {
return resolveRulesComponentRoot(discoveredRoot);
}
return fileURLToPath(new URL("../../..", moduleUrl));
}
function findNearestPluginRoot(startDirectory: string): string | null {
let currentDirectory = resolve(startDirectory);
while (true) {
if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) {
return currentDirectory;
}
const parentDirectory = dirname(currentDirectory);
if (parentDirectory === currentDirectory) {
return null;
}
currentDirectory = parentDirectory;
}
}
function resolveRulesComponentRoot(pluginRoot: string): string {
const componentRoot = join(pluginRoot, "components", "rules");
return isDirectory(componentRoot) ? componentRoot : pluginRoot;
}
function isFile(path: string): boolean {
try {
return statSync(path).isFile();
} catch {
return false;
}
}
function isDirectory(path: string): boolean {
try {
return statSync(path).isDirectory();
} catch {
return false;
}
}
@@ -84,6 +84,7 @@ export type RuleSource =
| "AGENTS.md"
| "CLAUDE.md"
| "CONTEXT.md"
| "plugin-bundled"
| "~/.omo/rules"
| "~/.opencode/rules"
| "~/.claude/rules"
@@ -0,0 +1,107 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { configFromEnvironment } from "../src/config.js";
import { SOURCE_PRIORITY } from "../src/rules/constants.js";
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
import { resolvePluginRulesRoot } from "../src/rules/plugin-root.js";
import type { RuleCandidate } from "../src/rules/types.js";
const projectRoot = "/tmp/codex-rules-bundled-priority";
const bundledPath = join(projectRoot, "bundled-rules", "hephaestus.md");
const homePath = join(projectRoot, "home", ".opencode", "rules", "hephaestus.md");
const bundledBody = "Bundled baseline discipline.";
const homeBody = "Home baseline discipline override.";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function globalCandidate(source: "plugin-bundled" | "~/.opencode/rules", path: string): RuleCandidate {
return {
path,
realPath: path,
source,
distance: 9999,
isGlobal: true,
isSingleFile: false,
relativePath: source === "plugin-bundled" ? "bundled-rules/hephaestus.md" : ".opencode/rules/hephaestus.md",
};
}
function ruleMarkdown(body: string): string {
return [
"---",
"description: OMO Hephaestus baseline discipline for Codex",
"alwaysApply: true",
"---",
"",
body,
].join("\n");
}
describe("plugin bundled rule priority", () => {
it("#given bundled source explicitly enabled then disabled #when parsing env #then no sources remain enabled", () => {
// given / when
const config = configFromEnvironment({
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
CODEX_RULES_DISABLE_BUNDLED: "1",
});
// then
expect(config.enabledSources).toEqual([]);
});
it("#given source priorities #when comparing user-home and bundled rules #then bundled has lower priority", () => {
// given / when / then
expect(SOURCE_PRIORITY.get("~/.opencode/rules")).toBe(101);
expect(SOURCE_PRIORITY.get("plugin-bundled")).toBe(200);
});
it("#given user-home and bundled rules share a description #when formatting static rules #then user-home wins", () => {
// given
const bundledCandidate = globalCandidate("plugin-bundled", bundledPath);
const homeCandidate = globalCandidate("~/.opencode/rules", homePath);
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [bundledCandidate, homeCandidate],
readFile: (path: string) => {
if (path === bundledPath) return ruleMarkdown(bundledBody);
if (path === homePath) return ruleMarkdown(homeBody);
return null;
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const loaded = engine.loadStaticRules(projectRoot);
const formatted = engine.formatStatic(loaded.rules);
// then
expect(formatted).toContain(homePath);
expect(formatted).toContain(homeBody);
expect(formatted).not.toContain(bundledPath);
expect(formatted).not.toContain(bundledBody);
});
it("#given aggregate plugin root #when resolving rules root #then components rules directory is selected", () => {
// given
const aggregateRoot = mkdtempSync(join(tmpdir(), "codex-rules-aggregate-plugin-"));
const componentRoot = join(aggregateRoot, "components", "rules");
tempDirectories.push(aggregateRoot);
mkdirSync(join(aggregateRoot, ".codex-plugin"), { recursive: true });
mkdirSync(componentRoot, { recursive: true });
writeFileSync(join(aggregateRoot, ".codex-plugin", "plugin.json"), JSON.stringify({ name: "omo" }));
// when
const resolvedRoot = resolvePluginRulesRoot(aggregateRoot);
// then
expect(resolvedRoot).toBe(componentRoot);
});
});
@@ -0,0 +1,215 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
type CodexPostCompactInput,
type CodexSessionStartInput,
runPostCompactHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "../src/rules/finder.js";
interface FixtureOptions {
readonly writeProjectDuplicate?: boolean;
}
interface Fixture {
readonly root: string;
readonly pluginRoot: string;
readonly pluginData: string;
readonly bundledRulePath: string;
readonly projectRulePath: string;
}
const BUNDLED_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
};
const PROJECT_AND_BUNDLED_ENV = {
CODEX_RULES_ENABLED_SOURCES: ".omo/rules,plugin-bundled",
};
const DISABLED_BUNDLED_ENV = {
CODEX_RULES_ENABLED_SOURCES: "plugin-bundled",
CODEX_RULES_DISABLE_BUNDLED: "1",
};
const BUNDLED_BODY = "Bundled craftsman baseline.";
const SHARED_BODY = "Always choose the smallest correct change.";
const tempDirectories: string[] = [];
let originalPluginRoot: string | undefined;
beforeEach(() => {
originalPluginRoot = process.env["PLUGIN_ROOT"];
});
afterEach(() => {
restoreEnv("PLUGIN_ROOT", originalPluginRoot);
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeFixture(options: FixtureOptions = {}): Fixture {
const root = mkdtempSync(join(tmpdir(), "codex-rules-bundled-project-"));
const pluginRoot = mkdtempSync(join(tmpdir(), "codex-rules-bundled-plugin-"));
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-bundled-data-"));
tempDirectories.push(root, pluginRoot, pluginData);
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "fixture" }));
mkdirSync(join(root, ".omo", "rules"), { recursive: true });
mkdirSync(join(pluginRoot, "bundled-rules"), { recursive: true });
const bundledRulePath = join(pluginRoot, "bundled-rules", "hephaestus.md");
const bundledBody = options.writeProjectDuplicate === true ? SHARED_BODY : BUNDLED_BODY;
writeFileSync(bundledRulePath, ruleMarkdown(bundledBody));
const projectRulePath = join(root, ".omo", "rules", "hephaestus.md");
if (options.writeProjectDuplicate === true) {
writeFileSync(projectRulePath, ruleMarkdown(SHARED_BODY));
}
process.env["PLUGIN_ROOT"] = pluginRoot;
return { root, pluginRoot, pluginData, bundledRulePath, projectRulePath };
}
function ruleMarkdown(body: string): string {
return ["---", "description: Fixture", "alwaysApply: true", "---", "", body].join("\n");
}
function restoreEnv(name: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-1",
transcript_path: null,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "startup",
};
}
function postCompactInput(root: string): CodexPostCompactInput {
return {
session_id: "session-1",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(root: string): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "continue",
};
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
describe("plugin bundled rules", () => {
it("#given PLUGIN_ROOT with bundled markdown #when finding candidates #then plugin-bundled source is cached", () => {
// given
const { pluginRoot } = makeFixture();
const cache = createRuleDiscoveryCache();
// when
const candidates = findRuleCandidates({ projectRoot: null, targetFile: null, skipUserHome: true, cache });
// then
expect(candidates.map((candidate) => `${candidate.source}:${candidate.relativePath}`)).toEqual([
"plugin-bundled:bundled-rules/hephaestus.md",
]);
expect(cache.scannedRuleFiles.has(join(pluginRoot, "bundled-rules"))).toBe(true);
});
it("#given alwaysApply bundled rule #when SessionStart runs #then static context includes it", async () => {
// given
const { root, pluginData } = makeFixture();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
// then
expect(output).toContain('"hookEventName":"SessionStart"');
expect(output).toContain(BUNDLED_BODY);
});
it("#given same project and bundled body #when SessionStart runs #then project rule wins", async () => {
// given
const { root, pluginData, bundledRulePath, projectRulePath } = makeFixture({ writeProjectDuplicate: true });
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_AND_BUNDLED_ENV,
});
// then
expect(occurrenceCount(output, SHARED_BODY)).toBe(1);
expect(output).toContain(projectRulePath);
expect(output).not.toContain(bundledRulePath);
});
it("#given bundled rules disabled #when SessionStart runs #then bundled context is suppressed", async () => {
// given
const { root, pluginData } = makeFixture();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: DISABLED_BUNDLED_ENV,
});
// then
expect(output).toBe("");
});
it("#given PostCompact pending flag #when UserPromptSubmit runs #then bundled static context re-injects", async () => {
// given
const { root, pluginData } = makeFixture();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
expect(firstOutput).toContain(BUNDLED_BODY);
// when
const compactOutput = await runPostCompactHook(postCompactInput(root), { pluginDataRoot: pluginData });
const output = await runUserPromptSubmitHook(userPromptSubmitInput(root), {
pluginDataRoot: pluginData,
env: BUNDLED_ONLY_ENV,
});
// then
expect(compactOutput).toBe("");
expect(output).toContain(BUNDLED_BODY);
});
});
@@ -43,7 +43,12 @@ describe("findRuleCandidates", () => {
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot });
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set(["plugin-bundled"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
@@ -64,7 +69,7 @@ describe("findRuleCandidates", () => {
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set([".omo/rules", "~/.opencode/rules"]),
disabledSources: new Set([".omo/rules", "~/.opencode/rules", "plugin-bundled"]),
});
// then
@@ -84,6 +89,7 @@ describe("findRuleCandidates", () => {
targetFile: targetPath,
homeDir: homeRoot,
skipUserHome: true,
disabledSources: new Set(["plugin-bundled"]),
});
// then