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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user