vendor: import codex-plugins as packages/omo-codex/{plugin,scripts,marketplace.json,MARKETPLACE.md}

This commit is contained in:
YeonGyu-Kim
2026-05-25 22:24:37 +09:00
parent 06c86f526a
commit 2415f37bc0
260 changed files with 22715 additions and 0 deletions
@@ -0,0 +1,143 @@
#!/usr/bin/env node
import { stdin as processStdin, stdout as processStdout } from "node:process";
import {
type CodexPostCompactInput,
type CodexPostToolUseInput,
type CodexRulesHookOptions,
type CodexSessionStartInput,
type CodexUserPromptSubmitInput,
runPostCompactHook,
runPostToolUseHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "./codex-hook.js";
const command = process.argv[2];
const subcommand = process.argv[3];
type HookCliEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse" | "PostCompact";
if (command === "hook" && subcommand === "session-start") {
await runHookCli("SessionStart");
} else if (command === "hook" && subcommand === "user-prompt-submit") {
await runHookCli("UserPromptSubmit");
} else if (command === "hook" && subcommand === "post-tool-use") {
await runHookCli("PostToolUse");
} else if (command === "hook" && subcommand === "post-compact") {
await runHookCli("PostCompact");
} else {
process.stderr.write("Usage: codex-rules hook [session-start|user-prompt-submit|post-tool-use|post-compact]\n");
process.exitCode = 1;
}
async function runHookCli(eventName: HookCliEventName): Promise<void> {
const raw = await readStdin();
if (raw.trim().length === 0) return;
const parsed = parseHookInput(raw);
if (!parsed) return;
const pluginDataRoot = process.env["PLUGIN_DATA"];
const options: CodexRulesHookOptions = pluginDataRoot === undefined ? {} : { pluginDataRoot };
const output = await runHook(eventName, parsed, options);
if (output.length > 0) {
processStdout.write(output);
}
}
async function runHook(eventName: HookCliEventName, parsed: unknown, options: CodexRulesHookOptions): Promise<string> {
switch (eventName) {
case "SessionStart":
return isCodexSessionStartInput(parsed) ? await runSessionStartHook(parsed, options) : "";
case "UserPromptSubmit":
return isCodexUserPromptSubmitInput(parsed) ? await runUserPromptSubmitHook(parsed, options) : "";
case "PostToolUse":
return isCodexPostToolUseInput(parsed) ? await runPostToolUseHook(parsed, options) : "";
case "PostCompact":
return isCodexPostCompactInput(parsed) ? await runPostCompactHook(parsed, options) : "";
}
}
function parseHookInput(raw: string): unknown | undefined {
try {
const parsed: unknown = JSON.parse(raw);
return parsed;
} catch {
return undefined;
}
}
function isCodexSessionStartInput(value: unknown): value is CodexSessionStartInput {
return (
isRecord(value) &&
value["hook_event_name"] === "SessionStart" &&
typeof value["session_id"] === "string" &&
isStringOrNull(value["transcript_path"]) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["source"] === "string"
);
}
function isCodexUserPromptSubmitInput(value: unknown): value is CodexUserPromptSubmitInput {
return (
isRecord(value) &&
value["hook_event_name"] === "UserPromptSubmit" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
isStringOrNull(value["transcript_path"]) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["prompt"] === "string"
);
}
function isCodexPostToolUseInput(value: unknown): value is CodexPostToolUseInput {
return (
isRecord(value) &&
value["hook_event_name"] === "PostToolUse" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
isStringOrNull(value["transcript_path"]) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
typeof value["permission_mode"] === "string" &&
typeof value["tool_name"] === "string" &&
typeof value["tool_use_id"] === "string"
);
}
function isCodexPostCompactInput(value: unknown): value is CodexPostCompactInput {
return (
isRecord(value) &&
value["hook_event_name"] === "PostCompact" &&
typeof value["session_id"] === "string" &&
typeof value["turn_id"] === "string" &&
isStringOrNull(value["transcript_path"]) &&
typeof value["cwd"] === "string" &&
typeof value["model"] === "string" &&
(value["trigger"] === "manual" || value["trigger"] === "auto")
);
}
function isStringOrNull(value: unknown): value is string | null {
return typeof value === "string" || value === null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let data = "";
processStdin.setEncoding("utf8");
processStdin.on("data", (chunk: string) => {
data += chunk;
});
processStdin.once("error", reject);
processStdin.once("end", () => {
resolve(data);
});
});
}
@@ -0,0 +1,475 @@
import { readFileSync, statSync } from "node:fs";
import { isAbsolute, relative, resolve } from "node:path";
import { configFromEnvironment } from "./config.js";
import { createHookDebugTimer } from "./debug-log.js";
import {
clearSessionState,
hasPostCompactPending,
hydrateEngineState,
isPostCompactPending,
markSessionCompacted,
persistEngineState,
sessionCachePath,
} from "./persistent-cache.js";
import { SOURCE_PRIORITY } from "./rules/constants.js";
import { createEngine } from "./rules/engine.js";
import { createRuleDiscoveryCache, findRuleCandidates } from "./rules/finder.js";
import { hashContent } from "./rules/matcher.js";
import { sortCandidates } from "./rules/ordering.js";
import { findProjectRoot } from "./rules/project-root.js";
import type { LoadedRule, PiRulesConfig, RuleCandidate } from "./rules/types.js";
import { extractCodexToolPaths } from "./tool-paths.js";
type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
export type CodexSessionStartInput = {
session_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "SessionStart";
model: string;
permission_mode: string;
source: "startup" | "resume" | "clear";
};
export type CodexUserPromptSubmitInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "UserPromptSubmit";
model: string;
permission_mode: string;
prompt: string;
};
export type CodexPostToolUseInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "PostToolUse";
model: string;
permission_mode: string;
tool_name: string;
tool_input: unknown;
tool_response: unknown;
tool_use_id: string;
};
export type CodexPostCompactInput = {
session_id: string;
turn_id: string;
transcript_path: string | null;
cwd: string;
hook_event_name: "PostCompact";
model: string;
trigger: "manual" | "auto";
};
export interface CodexRulesHookOptions {
env?: NodeJS.ProcessEnv;
pluginDataRoot?: string;
}
interface DynamicTargetFingerprint {
targetPath: string;
cacheKey: string;
fingerprint: string;
}
export async function runSessionStartHook(
input: CodexSessionStartInput,
options: CodexRulesHookOptions = {},
): Promise<string> {
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
if (input.source === "clear") {
clearSessionState(cachePath);
} else if (input.source !== "resume" && !hasPostCompactPending(cachePath)) {
clearSessionState(cachePath);
}
const postCompactPending = input.source !== "clear" && isPostCompactPending(cachePath, "static");
const transcriptPath = input.source === "clear" || postCompactPending ? null : input.transcript_path;
return runStaticInjection(
input.cwd,
transcriptPath,
"SessionStart",
cachePath,
options,
postCompactPending ? "static" : undefined,
);
}
export async function runPostCompactHook(
input: CodexPostCompactInput,
options: CodexRulesHookOptions = {},
): Promise<string> {
markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot));
return "";
}
export async function runUserPromptSubmitHook(
input: CodexUserPromptSubmitInput,
options: CodexRulesHookOptions = {},
): Promise<string> {
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
const postCompactPending = isPostCompactPending(cachePath, "static");
const transcriptPath = postCompactPending ? null : input.transcript_path;
return runStaticInjection(
input.cwd,
transcriptPath,
"UserPromptSubmit",
cachePath,
options,
postCompactPending ? "static" : undefined,
);
}
export async function runPostToolUseHook(
input: CodexPostToolUseInput,
options: CodexRulesHookOptions = {},
): Promise<string> {
const debugTimer = createHookDebugTimer("PostToolUse");
const config = configFromEnvironment(options.env);
debugTimer.lap("config", { disabled: config.disabled, mode: config.mode });
if (config.disabled || config.mode === "off" || config.mode === "static") {
debugTimer.done({ outputBytes: 0, reason: "disabled" });
return "";
}
const targetPaths = extractCodexToolPaths(input, input.cwd);
debugTimer.lap("extract", {
targets: targetPaths.length,
uniqueTargets: uniqueStrings(targetPaths).length,
tool: input.tool_name,
});
const firstTargetPath = targetPaths[0];
if (firstTargetPath === undefined) {
debugTimer.done({ outputBytes: 0, reason: "no-target" });
return "";
}
const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot);
const postCompactPending = isPostCompactPending(cachePath, "dynamic");
const transcriptPath = postCompactPending ? null : input.transcript_path;
const engine = createRulesEngine(options);
hydrateEngineState(engine, cachePath);
debugTimer.lap("hydrate", {
dynamicDedupScopes: engine.state.dynamicDedup.size,
dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size,
staticDedup: engine.state.staticDedup.size,
});
const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config);
debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length });
const pendingTargetFingerprints = dynamicTargetFingerprints.filter(
(target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint,
);
debugTimer.lap("pending", { pending: pendingTargetFingerprints.length });
if (pendingTargetFingerprints.length === 0) {
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
debugTimer.lap("persist", { reason: "no-pending" });
debugTimer.done({ outputBytes: 0, reason: "no-pending" });
return "";
}
const loaded = engine.loadDynamicRules(
input.cwd,
pendingTargetFingerprints.map((target) => target.targetPath),
);
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
const rules = filterRulesAlreadyInTranscript(
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
transcriptPath,
(rule) => {
engine.markDynamicInjected(rule);
},
);
debugTimer.lap("filter", { rules: rules.length });
for (const target of pendingTargetFingerprints) {
engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint);
}
if (rules.length === 0) {
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
debugTimer.lap("persist", { reason: "no-rules" });
debugTimer.done({ outputBytes: 0, reason: "no-rules" });
return "";
}
const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath;
const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath));
debugTimer.lap("format", { blockChars: block.length, rules: rules.length });
for (const rule of rules) {
engine.markDynamicInjected(rule);
}
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
debugTimer.lap("persist", { reason: "emit" });
const output = formatAdditionalContextOutput("PostToolUse", block);
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
return output;
}
function runStaticInjection(
cwd: string,
transcriptPath: string | null,
eventName: "SessionStart" | "UserPromptSubmit",
cachePath: string,
options: CodexRulesHookOptions,
completedPostCompactChannel?: "static",
): string {
const config = configFromEnvironment(options.env);
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
return "";
}
const engine = createRulesEngine(options);
hydrateEngineState(engine, cachePath);
engine.state.cwd = cwd;
const loaded = engine.loadStaticRules(cwd);
const rules = filterRulesAlreadyInTranscript(
loaded.rules.filter((rule) => !engine.isStaticInjected(rule)),
transcriptPath,
(rule) => {
engine.markStaticInjected(rule);
},
);
if (rules.length === 0) {
persistEngineState(engine, cachePath, completedPostCompactChannel);
return "";
}
const block = engine.formatStatic(rules);
for (const rule of rules) {
engine.markStaticInjected(rule);
}
persistEngineState(engine, cachePath, completedPostCompactChannel);
return formatAdditionalContextOutput(eventName, block);
}
function filterRulesAlreadyInTranscript(
rules: ReadonlyArray<LoadedRule>,
transcriptPath: string | null,
markInjected: (rule: LoadedRule) => void,
): LoadedRule[] {
if (rules.length === 0 || transcriptPath === null) {
return [...rules];
}
const transcriptText = readTranscriptSearchText(transcriptPath);
if (transcriptText === null) {
return [...rules];
}
const pendingRules: LoadedRule[] = [];
for (const rule of rules) {
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
markInjected(rule);
continue;
}
pendingRules.push(rule);
}
return pendingRules;
}
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
const bodyNeedle = rule.body.trim().slice(0, 2_000);
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
return false;
}
const markers = [
`Instructions from: ${rule.path}`,
`Instructions from: ${rule.realPath}`,
rule.relativePath.length === 0 ? null : rule.relativePath,
].filter((marker): marker is string => marker !== null);
return markers.some((marker) => transcriptText.includes(marker));
}
function readTranscriptSearchText(transcriptPath: string): string | null {
try {
const rawTranscript = readFileSync(transcriptPath, "utf8");
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
} catch {
return null;
}
}
function collectJsonLineStrings(rawTranscript: string): string[] {
const values: string[] = [];
for (const line of rawTranscript.split(/\r?\n/)) {
if (line.trim().length === 0) {
continue;
}
try {
const parsed: unknown = JSON.parse(line);
collectStrings(parsed, values);
} catch {
// Non-JSON transcript lines are still covered by the raw transcript text.
}
}
return values;
}
function collectStrings(value: unknown, output: string[]): void {
if (typeof value === "string") {
output.push(value);
return;
}
if (Array.isArray(value)) {
for (const item of value) {
collectStrings(item, output);
}
return;
}
if (typeof value !== "object" || value === null) {
return;
}
for (const item of Object.values(value)) {
collectStrings(item, output);
}
}
function createRulesEngine(options: CodexRulesHookOptions) {
const config = configFromEnvironment(options.env);
return createEngine(config, {
findCandidates: findRuleCandidates,
findProjectRoot,
readFile: (path) => {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
},
});
}
function fingerprintDynamicTargets(
cwd: string,
targetPaths: ReadonlyArray<string>,
config: PiRulesConfig,
): DynamicTargetFingerprint[] {
const disabledSources = disabledSourcesFor(config);
const discoveryCache = createRuleDiscoveryCache();
const cwdProjectRoot = findProjectRoot(cwd);
const fingerprints: DynamicTargetFingerprint[] = [];
for (const targetPath of uniqueStrings(targetPaths)) {
const projectRoot =
cwdProjectRoot !== null && isSameOrChildPath(targetPath, cwdProjectRoot)
? cwdProjectRoot
: findProjectRoot(targetPath);
const findOptions: {
projectRoot: string | null;
targetFile: string;
disabledSources?: ReadonlySet<string>;
cache: ReturnType<typeof createRuleDiscoveryCache>;
} = {
projectRoot,
targetFile: targetPath,
cache: discoveryCache,
};
if (disabledSources !== undefined) {
findOptions.disabledSources = disabledSources;
}
const candidates = findRuleCandidates(findOptions);
const candidateFingerprint = sortCandidates(candidates).map(fingerprintCandidate).join("\u0001");
const cacheKey = dynamicTargetCacheKey(targetPath);
fingerprints.push({
targetPath,
cacheKey,
fingerprint: hashContent(
[
"v1",
config.enabledSources === "auto" ? "auto" : config.enabledSources.join(","),
projectRoot ?? "",
cacheKey,
candidateFingerprint,
].join("\u0000"),
),
});
}
return fingerprints;
}
function fingerprintCandidate(candidate: RuleCandidate): string {
return [
candidate.realPath,
candidate.relativePath,
candidate.source,
candidate.isGlobal ? "global" : "project",
candidate.isSingleFile ? "single" : "multi",
String(candidate.distance),
fileFingerprint(candidate.path),
].join("\u0000");
}
function fileFingerprint(filePath: string): string {
try {
const stats = statSync(filePath, { bigint: true });
return `${stats.mtimeNs}:${stats.ctimeNs}:${stats.size}`;
} catch {
return "missing";
}
}
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
if (config.enabledSources === "auto") {
return undefined;
}
const enabledSources = new Set(config.enabledSources);
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
}
function dynamicTargetCacheKey(targetPath: string): string {
return toPosixPath(resolve(targetPath));
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, resolve(childPath));
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
}
function uniqueStrings(values: ReadonlyArray<string>): string[] {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
for (const value of values) {
if (seenValues.has(value)) {
continue;
}
seenValues.add(value);
uniqueValues.push(value);
}
return uniqueValues;
}
function formatAdditionalContextOutput(eventName: ContextInjectionHookEventName, additionalContext: string): string {
if (additionalContext.trim().length === 0) return "";
return `${JSON.stringify({
hookSpecificOutput: {
hookEventName: eventName,
additionalContext,
},
})}\n`;
}
function displayPath(cwd: string, filePath: string): string {
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
// Normalize to POSIX separators so injected rule context renders the same
// path string on Linux/macOS and Windows (Codex feeds this verbatim into
// the model prompt, and the existing engine already emits POSIX paths).
return toPosixPath(rel);
}
function toPosixPath(path: string): string {
return path.replaceAll("\\", "/");
}
@@ -0,0 +1,65 @@
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();
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 =
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ??
config.maxRuleChars;
config.maxResultChars =
parsePositiveInteger(firstEnv(env, "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
config.maxResultChars;
config.enabledSources = parseEnabledSources(
firstEnv(env, "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
);
return config;
}
function firstEnv(env: NodeJS.ProcessEnv, ...names: string[]): string | undefined {
for (const name of names) {
const value = env[name];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
return undefined;
}
function isTruthy(value: string | undefined): boolean {
if (value === undefined) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
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;
}
function parsePositiveInteger(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const parsed = Number.parseInt(value.trim(), 10);
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined;
}
function parseEnabledSources(value: string | undefined): RuleSource[] | "auto" {
if (value === undefined || value.trim().toLowerCase() === "auto") {
return "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)) {
continue;
}
sources.push(source as RuleSource);
}
return sources.length > 0 ? sources : "auto";
}
@@ -0,0 +1,65 @@
import { performance } from "node:perf_hooks";
import { debuglog } from "node:util";
type DebugFieldValue = boolean | number | string | null;
type DebugFields = Record<string, DebugFieldValue>;
const debug = debuglog("codex-rules");
const noopTimer: HookDebugTimer = {
lap: () => {},
done: () => {},
};
export interface HookDebugTimer {
lap(phase: string, fields?: DebugFields): void;
done(fields?: DebugFields): void;
}
export function createHookDebugTimer(hookName: string): HookDebugTimer {
if (!debug.enabled) {
return noopTimer;
}
const startMs = performance.now();
let lastMs = startMs;
return {
lap: (phase, fields = {}) => {
const nowMs = performance.now();
writeDebugLine(hookName, phase, nowMs - lastMs, nowMs - startMs, fields);
lastMs = nowMs;
},
done: (fields = {}) => {
const nowMs = performance.now();
writeDebugLine(hookName, "done", nowMs - lastMs, nowMs - startMs, fields);
lastMs = nowMs;
},
};
}
function writeDebugLine(
hookName: string,
phase: string,
durationMs: number,
totalMs: number,
fields: DebugFields,
): void {
debug(
"%s phase=%s ms=%s total_ms=%s%s",
hookName,
phase,
durationMs.toFixed(3),
totalMs.toFixed(3),
formatFields(fields),
);
}
function formatFields(fields: DebugFields): string {
const entries = Object.entries(fields);
if (entries.length === 0) {
return "";
}
return ` ${entries.map(([key, value]) => `${key}=${String(value)}`).join(" ")}`;
}
@@ -0,0 +1,167 @@
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join } from "node:path";
import type { Engine } from "./rules/engine.js";
export type PostCompactPendingKind = "static" | "dynamic";
interface PostCompactPendingState {
static?: boolean;
dynamic?: boolean;
}
interface SerializedSessionState {
staticDedup: string[];
dynamicDedup: Record<string, string[]>;
dynamicTargetFingerprints?: Record<string, string>;
postCompactPending?: PostCompactPendingState;
compacted?: boolean;
}
export function hydrateEngineState(engine: Engine, cachePath: string): void {
const state = readSessionState(cachePath);
engine.state.staticDedup.clear();
engine.state.dynamicDedup.clear();
engine.state.dynamicTargetFingerprints.clear();
for (const key of state.staticDedup) {
engine.state.staticDedup.add(key);
}
for (const [scope, keys] of Object.entries(state.dynamicDedup)) {
engine.state.dynamicDedup.set(scope, new Set(keys));
}
for (const [targetKey, fingerprint] of Object.entries(state.dynamicTargetFingerprints ?? {})) {
engine.state.dynamicTargetFingerprints.set(targetKey, fingerprint);
}
}
export function persistEngineState(
engine: Engine,
cachePath: string,
completedPostCompactKind?: PostCompactPendingKind,
): void {
const currentState = readSessionState(cachePath);
const dynamicDedup: Record<string, string[]> = {};
for (const [scope, keys] of engine.state.dynamicDedup.entries()) {
dynamicDedup[scope] = [...keys];
}
const postCompactPending = nextPostCompactPending(currentState, completedPostCompactKind);
writeSessionState(cachePath, {
staticDedup: [...engine.state.staticDedup],
dynamicDedup,
dynamicTargetFingerprints: Object.fromEntries(engine.state.dynamicTargetFingerprints.entries()),
...(postCompactPending === undefined ? {} : { postCompactPending }),
});
}
export function clearSessionState(cachePath: string): void {
rmSync(cachePath, { force: true });
}
export function markSessionCompacted(cachePath: string): void {
writeSessionState(cachePath, { ...emptyState(), postCompactPending: { static: true, dynamic: true } });
}
export function hasPostCompactPending(cachePath: string): boolean {
return postCompactPendingKinds(readSessionState(cachePath)).size > 0;
}
export function isPostCompactPending(cachePath: string, kind: PostCompactPendingKind): boolean {
return postCompactPendingKinds(readSessionState(cachePath)).has(kind);
}
export function sessionCachePath(sessionId: string, pluginDataRoot: string | undefined): string {
const root = pluginDataRoot ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".codex", "codex-rules");
return join(root, "sessions", `${safePathSegment(sessionId)}.json`);
}
function readSessionState(cachePath: string): SerializedSessionState {
try {
const parsed = JSON.parse(readFileSync(cachePath, "utf8"));
if (!isSerializedSessionState(parsed)) return emptyState();
return parsed;
} catch {
return emptyState();
}
}
function writeSessionState(cachePath: string, state: SerializedSessionState): void {
mkdirSync(dirname(cachePath), { recursive: true });
writeFileSync(cachePath, `${JSON.stringify(state)}\n`);
}
function emptyState(): SerializedSessionState {
return { staticDedup: [], dynamicDedup: {}, dynamicTargetFingerprints: {} };
}
function nextPostCompactPending(
state: SerializedSessionState,
completedKind: PostCompactPendingKind | undefined,
): PostCompactPendingState | undefined {
const pendingKinds = postCompactPendingKinds(state);
if (completedKind !== undefined) {
pendingKinds.delete(completedKind);
}
if (pendingKinds.size === 0) {
return undefined;
}
return {
...(pendingKinds.has("static") ? { static: true } : {}),
...(pendingKinds.has("dynamic") ? { dynamic: true } : {}),
};
}
function postCompactPendingKinds(state: SerializedSessionState): Set<PostCompactPendingKind> {
const pendingKinds = new Set<PostCompactPendingKind>();
if (state.compacted === true || state.postCompactPending?.static === true) {
pendingKinds.add("static");
}
if (state.compacted === true || state.postCompactPending?.dynamic === true) {
pendingKinds.add("dynamic");
}
return pendingKinds;
}
function safePathSegment(value: string): string {
return value.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120) || "unknown-session";
}
function isSerializedSessionState(value: unknown): value is SerializedSessionState {
if (!isRecord(value) || !Array.isArray(value["staticDedup"]) || !isRecord(value["dynamicDedup"])) {
return false;
}
const staticDedup = value["staticDedup"];
const dynamicDedup = value["dynamicDedup"];
const dynamicTargetFingerprints = value["dynamicTargetFingerprints"];
const postCompactPending = value["postCompactPending"];
const compacted = value["compacted"];
return (
staticDedup.every((item) => typeof item === "string") &&
Object.values(dynamicDedup).every(
(item) => Array.isArray(item) && item.every((nestedItem) => typeof nestedItem === "string"),
) &&
(dynamicTargetFingerprints === undefined ||
(isRecord(dynamicTargetFingerprints) &&
Object.entries(dynamicTargetFingerprints).every(
([targetKey, fingerprint]) => typeof targetKey === "string" && typeof fingerprint === "string",
))) &&
(postCompactPending === undefined || isPostCompactPendingState(postCompactPending)) &&
(compacted === undefined || typeof compacted === "boolean")
);
}
function isPostCompactPendingState(value: unknown): value is PostCompactPendingState {
return (
isRecord(value) &&
(value["static"] === undefined || typeof value["static"] === "boolean") &&
(value["dynamic"] === undefined || typeof value["dynamic"] === "boolean")
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,64 @@
import type { LoadedRule, SessionState } from "./types.js";
const DYNAMIC_SESSION_KEY = "__pi-rules-session__";
export function createSessionState(cwd?: string): SessionState {
return {
cwd,
staticDedup: new Set(),
dynamicDedup: new Map(),
dynamicTargetFingerprints: new Map(),
loadedRules: [],
diagnostics: [],
};
}
export function staticDedupKey(cwd: string, rulePath: string, contentHash: string): string {
return `${cwd}::${rulePath}::${contentHash}`;
}
export function dynamicDedupKey(rulePath: string, contentHash: string): string {
return `${rulePath}::${contentHash}`;
}
export function markStaticInjected(state: SessionState, rule: LoadedRule): boolean {
const key = staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash);
if (state.staticDedup.has(key)) {
return false;
}
state.staticDedup.add(key);
return true;
}
export function markDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
let keys = state.dynamicDedup.get(DYNAMIC_SESSION_KEY);
if (keys === undefined) {
keys = new Set();
state.dynamicDedup.set(DYNAMIC_SESSION_KEY, keys);
}
const key = dynamicDedupKey(rule.realPath, rule.contentHash);
if (keys.has(key)) {
return false;
}
keys.add(key);
return true;
}
export function isStaticInjected(state: SessionState, rule: LoadedRule): boolean {
return state.staticDedup.has(staticDedupKey(state.cwd ?? "", rule.realPath, rule.contentHash));
}
export function isDynamicInjected(state: SessionState, rule: LoadedRule): boolean {
return state.dynamicDedup.get(DYNAMIC_SESSION_KEY)?.has(dynamicDedupKey(rule.realPath, rule.contentHash)) === true;
}
export function clearSession(state: SessionState): void {
state.staticDedup.clear();
state.dynamicDedup.clear();
state.dynamicTargetFingerprints.clear();
state.loadedRules.length = 0;
state.diagnostics.length = 0;
}
@@ -0,0 +1,105 @@
import type { RuleSource } from "./types.js";
/**
* Project root marker files / directories used by `findProjectRoot`.
* Walks UP from cwd until any of these is found in the directory.
*/
export const PROJECT_MARKERS: readonly string[] = [
".git",
"pnpm-workspace.yaml",
"package.json",
"pyproject.toml",
"Cargo.toml",
"go.mod",
".venv",
];
/**
* Project rule subdirectories. First tuple element is the parent dir under
* the project root, second is the subdir scanned recursively.
*/
export const PROJECT_RULE_SUBDIRS: ReadonlyArray<readonly [string, string]> = [
[".omo", "rules"],
[".claude", "rules"],
[".cursor", "rules"],
[".github", "instructions"],
];
/**
* Single-file project rules (always apply, frontmatter optional).
*/
export const PROJECT_SINGLE_FILES: readonly string[] = [
".github/copilot-instructions.md",
"AGENTS.md",
"CLAUDE.md",
"CONTEXT.md",
];
/**
* User-home rule directories.
*/
export const USER_HOME_RULE_SUBDIRS: readonly string[] = [".omo/rules", ".opencode/rules", ".claude/rules"];
/**
* User-home single-file rules. The first one to exist wins per "first-match" semantics.
*/
export const USER_HOME_SINGLE_FILES: readonly string[] = [".config/opencode/AGENTS.md", ".claude/CLAUDE.md"];
/**
* File extensions accepted as rule files in scanned directories.
*/
export const RULE_FILE_EXTENSIONS: readonly string[] = [".md", ".mdc"];
/**
* Per-rule source priority for deterministic ordering. Lower = earlier.
*/
export const SOURCE_PRIORITY: ReadonlyMap<RuleSource, number> = new Map([
[".omo/rules", 0],
[".claude/rules", 1],
[".cursor/rules", 2],
[".github/instructions", 3],
[".github/copilot-instructions.md", 4],
["AGENTS.md", 5],
["CLAUDE.md", 6],
["CONTEXT.md", 7],
["~/.omo/rules", 100],
["~/.opencode/rules", 101],
["~/.claude/rules", 102],
["~/.config/opencode/AGENTS.md", 103],
["~/.claude/CLAUDE.md", 104],
]);
/**
* Distance value assigned to global / user-home rules.
*/
export const GLOBAL_DISTANCE = 9999;
/**
* Per-rule body character cap (default).
*/
export const DEFAULT_MAX_RULE_CHARS = 12000;
export const DEFAULT_MAX_SCAN_FILES = 1000;
/**
* Total injected chars per tool result (default).
*/
export const DEFAULT_MAX_RESULT_CHARS = 40000;
/**
* Truncation marker template. `{path}` is replaced with the relative path.
*/
export const TRUNCATION_NOTICE = "\n\n[Rule truncated. Read full rule: {path}]";
/**
* Directories excluded by the recursive scanner regardless of glob settings.
*/
export const SCANNER_EXCLUDED_DIRS: readonly string[] = [
"node_modules",
".git",
"dist",
"build",
".turbo",
".next",
"coverage",
];
@@ -0,0 +1,531 @@
import { realpathSync } from "node:fs";
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
import {
clearSession,
createSessionState,
isDynamicInjected as isDynamicInjectedInState,
isStaticInjected as isStaticInjectedInState,
markDynamicInjected as markDynamicInjectedInState,
markStaticInjected as markStaticInjectedInState,
} from "./cache.js";
import {
DEFAULT_MAX_RESULT_CHARS,
DEFAULT_MAX_RULE_CHARS,
PROJECT_SINGLE_FILES,
SOURCE_PRIORITY,
} from "./constants.js";
import { createRuleDiscoveryCache, type RuleDiscoveryCache } from "./finder.js";
import { formatDynamicBlock, formatStaticBlock } from "./formatter.js";
import { hashContent, matchRule } from "./matcher.js";
import { sortCandidates } from "./ordering.js";
import { parseRule } from "./parser.js";
import type { LoadedRule, MatchReason, PiRulesConfig, RuleCandidate, RuleDiagnostic, SessionState } from "./types.js";
interface LoadedRuleContent {
frontmatter: LoadedRule["frontmatter"];
body: string;
contentHash: string;
diagnostic?: string;
}
type CandidateProjectMembership = Map<string, boolean>;
type CandidateDiscoveryCache = Map<string, RuleCandidate[]>;
type DynamicMatchCache = Map<string, MatchReason | null>;
const MAX_DYNAMIC_MATCH_CACHE_ENTRIES = 4096;
export interface EngineDeps {
findCandidates: (options: {
projectRoot: string | null;
targetFile: string | null;
homeDir?: string;
disabledSources?: ReadonlySet<string>;
skipUserHome?: boolean;
cache?: RuleDiscoveryCache;
}) => RuleCandidate[];
readFile: (path: string) => string | null;
findProjectRoot: (startPath: string) => string | null;
matchRule?: typeof matchRule;
}
export interface Engine {
state: SessionState;
config: PiRulesConfig;
loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
loadDynamicRules(
cwd: string,
targetPaths: ReadonlyArray<string>,
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] };
formatStatic(rules: ReadonlyArray<LoadedRule>): string;
formatDynamic(rules: ReadonlyArray<LoadedRule>, target: string): string;
resetSession(cwd?: string): void;
isStaticInjected(rule: LoadedRule): boolean;
isDynamicInjected(rule: LoadedRule): boolean;
markStaticInjected(rule: LoadedRule): boolean;
markDynamicInjected(rule: LoadedRule): boolean;
}
const ROOT_SINGLE_FILE_SOURCES = new Set(PROJECT_SINGLE_FILES.filter((source) => !source.includes("/")));
export function defaultConfig(): PiRulesConfig {
return {
disabled: false,
mode: "both",
maxRuleChars: DEFAULT_MAX_RULE_CHARS,
maxResultChars: DEFAULT_MAX_RESULT_CHARS,
enabledSources: "auto",
};
}
export function createEngine(config: PiRulesConfig, deps: EngineDeps): Engine {
const state = createSessionState();
const dynamicMatchCache: DynamicMatchCache = new Map();
function loadStaticRules(cwd: string): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
state.cwd = cwd;
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
return emptyLoadResult(state);
}
const projectRoot = deps.findProjectRoot(cwd);
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
projectRoot,
targetFile: null,
};
const disabledSources = disabledSourcesFor(config);
if (disabledSources !== undefined) {
findOptions.disabledSources = disabledSources;
}
const candidates = deps.findCandidates(findOptions);
const result = loadStaticCandidates(candidates, deps, projectRoot);
storeLastLoad(state, result.rules, result.diagnostics);
return result;
}
function loadDynamicRules(
cwd: string,
targetPaths: ReadonlyArray<string>,
): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
state.cwd = cwd;
if (config.disabled || config.mode === "off" || config.mode === "static" || targetPaths.length === 0) {
return emptyLoadResult(state);
}
const rules: LoadedRule[] = [];
const diagnostics: RuleDiagnostic[] = [];
const seenRules = new Set<string>();
const loadedRuleContent = new Map<string, LoadedRuleContent | null>();
const projectMembership = new Map<string, boolean>();
const disabledSources = disabledSourcesFor(config);
const discoveryCache = createRuleDiscoveryCache();
const candidateDiscoveryCache: CandidateDiscoveryCache = new Map();
const cwdProjectRoot = deps.findProjectRoot(cwd);
for (const targetFile of uniqueStrings(targetPaths)) {
const projectRoot =
cwdProjectRoot !== null && isSameOrChildPath(targetFile, cwdProjectRoot)
? cwdProjectRoot
: deps.findProjectRoot(targetFile);
const findOptions: Parameters<EngineDeps["findCandidates"]>[0] = {
projectRoot,
targetFile,
cache: discoveryCache,
};
if (disabledSources !== undefined) {
findOptions.disabledSources = disabledSources;
}
const candidates = findSortedCandidatesCached(candidateDiscoveryCache, deps.findCandidates, findOptions);
for (const candidate of candidates) {
const loadedRule = loadCandidate(
candidate,
deps,
diagnostics,
projectRoot,
loadedRuleContent,
projectMembership,
);
if (loadedRule === null) {
continue;
}
const matchReason = matchDynamicRuleCached(
dynamicMatchCache,
projectRoot,
targetFile,
candidate,
loadedRule,
deps.matchRule ?? matchRule,
);
if (matchReason === null) {
continue;
}
const dedupKey = ruleDedupKey(loadedRule);
if (seenRules.has(dedupKey)) {
continue;
}
seenRules.add(dedupKey);
rules.push({ ...loadedRule, matchReason });
}
}
const sortedRules = sortCandidates(rules);
storeLastLoad(state, sortedRules, diagnostics);
return { rules: sortedRules, diagnostics };
}
return {
state,
config,
loadStaticRules,
loadDynamicRules,
formatStatic: (rules) =>
formatStaticBlock(rules, { maxRuleChars: config.maxRuleChars, maxResultChars: config.maxResultChars }),
formatDynamic: (rules, target) =>
formatDynamicBlock(rules, target, {
maxRuleChars: config.maxRuleChars,
maxResultChars: config.maxResultChars,
}),
resetSession: (cwd) => {
clearSession(state);
dynamicMatchCache.clear();
if (cwd !== undefined) {
state.cwd = cwd;
}
},
isStaticInjected: (rule) => isStaticInjectedInState(state, rule),
isDynamicInjected: (rule) => isDynamicInjectedInState(state, rule),
markStaticInjected: (rule) => markStaticInjectedInState(state, rule),
markDynamicInjected: (rule) => markDynamicInjectedInState(state, rule),
};
}
function matchDynamicRuleCached(
cache: DynamicMatchCache,
projectRoot: string | null,
targetFile: string,
candidate: RuleCandidate,
loadedRule: LoadedRule,
matchRuleImpl: typeof matchRule,
): MatchReason | null {
const cacheKey = dynamicMatchCacheKey(projectRoot, targetFile, candidate, loadedRule.contentHash);
if (cache.has(cacheKey)) {
const cachedReason = cache.get(cacheKey) ?? null;
cache.delete(cacheKey);
cache.set(cacheKey, cachedReason);
return cachedReason;
}
const matchResult = matchRuleImpl({
frontmatter: loadedRule.frontmatter,
isSingleFile: candidate.isSingleFile,
pathBases: pathBasesForTarget(projectRoot, targetFile, candidate),
});
const reason = matchResult.matched ? matchResult.reason : null;
setDynamicMatchCacheEntry(cache, cacheKey, reason);
return reason;
}
function setDynamicMatchCacheEntry(cache: DynamicMatchCache, cacheKey: string, reason: MatchReason | null): void {
if (cache.size >= MAX_DYNAMIC_MATCH_CACHE_ENTRIES) {
const oldestCacheKey = cache.keys().next().value;
if (oldestCacheKey !== undefined) {
cache.delete(oldestCacheKey);
}
}
cache.set(cacheKey, reason);
}
function dynamicMatchCacheKey(
projectRoot: string | null,
targetFile: string,
candidate: RuleCandidate,
contentHash: string,
): string {
return [
projectRoot ?? "",
toPosixPath(resolve(targetFile)),
candidate.realPath,
candidate.relativePath,
candidate.source,
candidate.isGlobal ? "global" : "project",
candidate.isSingleFile ? "single" : "multi",
String(candidate.distance),
contentHash,
].join("\0");
}
function loadStaticCandidates(candidates: ReadonlyArray<RuleCandidate>, deps: EngineDeps, projectRoot: string | null) {
const rules: LoadedRule[] = [];
const diagnostics: RuleDiagnostic[] = [];
let rootSingleFileSelected = false;
for (const candidate of sortCandidates(candidates)) {
if (isDedupedRootSingleFile(candidate, rootSingleFileSelected)) {
continue;
}
const loadedRule = loadCandidate(candidate, deps, diagnostics, projectRoot);
if (loadedRule === null) {
continue;
}
const matchReason = staticMatchReason(loadedRule);
if (matchReason === null) {
continue;
}
if (isRootSingleFile(candidate)) {
rootSingleFileSelected = true;
}
rules.push({ ...loadedRule, matchReason });
}
return { rules: sortCandidates(rules), diagnostics };
}
function loadCandidate(
candidate: RuleCandidate,
deps: EngineDeps,
diagnostics: RuleDiagnostic[],
projectRoot: string | null,
loadedRuleContent?: Map<string, LoadedRuleContent | null>,
projectMembership?: CandidateProjectMembership,
): (LoadedRule & { matchReason: MatchReason }) | null {
if (!isCandidateWithinProjectCached(candidate, projectRoot, projectMembership)) {
diagnostics.push({
severity: "warning",
source: candidate.path,
message: "Rule file resolves outside project root",
});
return null;
}
const cachedContent = loadedRuleContent?.get(candidate.realPath);
if (cachedContent !== undefined) {
return loadedRuleFromContent(candidate, cachedContent, diagnostics);
}
const content = deps.readFile(candidate.path);
if (content === null) {
loadedRuleContent?.set(candidate.realPath, null);
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
return null;
}
const parsed = parseRule(content);
const loadedContent = {
frontmatter: parsed.frontmatter,
body: parsed.body,
contentHash: hashContent(content),
...(parsed.diagnostic === undefined ? {} : { diagnostic: parsed.diagnostic }),
} satisfies LoadedRuleContent;
loadedRuleContent?.set(candidate.realPath, loadedContent);
return loadedRuleFromContent(candidate, loadedContent, diagnostics);
}
function loadedRuleFromContent(
candidate: RuleCandidate,
content: LoadedRuleContent | null,
diagnostics: RuleDiagnostic[],
): (LoadedRule & { matchReason: MatchReason }) | null {
if (content === null) {
diagnostics.push({ severity: "warning", source: candidate.path, message: "Unable to read rule file" });
return null;
}
if (content.diagnostic !== undefined) {
diagnostics.push({ severity: "warning", source: candidate.path, message: content.diagnostic });
}
return {
...candidate,
frontmatter: content.frontmatter,
body: content.body,
contentHash: content.contentHash,
matchReason: { kind: "no-match" },
};
}
function ruleDedupKey(rule: LoadedRule): string {
return `${rule.realPath}::${rule.contentHash}`;
}
function isCandidateWithinProject(candidate: RuleCandidate, projectRoot: string | null): boolean {
if (candidate.isGlobal) {
return true;
}
if (projectRoot === null) {
return false;
}
const relativeRealPath = relative(realPathOrResolved(projectRoot), realPathOrResolved(candidate.realPath));
return relativeRealPath === "" || (!relativeRealPath.startsWith("..") && !isAbsolute(relativeRealPath));
}
function isCandidateWithinProjectCached(
candidate: RuleCandidate,
projectRoot: string | null,
projectMembership: CandidateProjectMembership | undefined,
): boolean {
if (projectMembership === undefined) {
return isCandidateWithinProject(candidate, projectRoot);
}
const cacheKey = `${projectRoot ?? ""}\0${candidate.realPath}`;
const cached = projectMembership.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const isWithinProject = isCandidateWithinProject(candidate, projectRoot);
projectMembership.set(cacheKey, isWithinProject);
return isWithinProject;
}
function realPathOrResolved(path: string): string {
try {
return realpathSync.native(path);
} catch {
return resolve(path);
}
}
function findSortedCandidatesCached(
cache: CandidateDiscoveryCache,
findCandidates: EngineDeps["findCandidates"],
options: Parameters<EngineDeps["findCandidates"]>[0],
): RuleCandidate[] {
const cacheKey = candidateDiscoveryCacheKey(options);
const cached = cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const candidates = sortCandidates(findCandidates(options));
cache.set(cacheKey, candidates);
return candidates;
}
function candidateDiscoveryCacheKey(options: Parameters<EngineDeps["findCandidates"]>[0]): string {
return [
options.projectRoot ?? "",
options.targetFile === null ? "" : dirname(resolve(options.targetFile)),
...[...(options.disabledSources ?? [])].sort(),
].join("\0");
}
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
const childRelativePath = relative(parentPath, resolve(childPath));
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
}
function staticMatchReason(rule: LoadedRule): MatchReason | null {
if (rule.frontmatter.alwaysApply === true) {
return "alwaysApply";
}
if (rule.isSingleFile) {
return "single-file";
}
return null;
}
function disabledSourcesFor(config: PiRulesConfig): ReadonlySet<string> | undefined {
if (config.enabledSources === "auto") {
return undefined;
}
const enabledSources = new Set(config.enabledSources);
return new Set([...SOURCE_PRIORITY.keys()].filter((source) => !enabledSources.has(source)));
}
function isDedupedRootSingleFile(candidate: RuleCandidate, rootSingleFileSelected: boolean): boolean {
return rootSingleFileSelected && isRootSingleFile(candidate);
}
function isRootSingleFile(candidate: RuleCandidate): boolean {
return candidate.distance === 0 && candidate.isSingleFile && ROOT_SINGLE_FILE_SOURCES.has(candidate.source);
}
function pathBasesForTarget(
projectRoot: string | null,
targetFile: string,
candidate: RuleCandidate,
): { projectRelative: string; scopeRelative?: string; basename: string } {
const targetBasename = basename(targetFile);
if (projectRoot === null) {
return { projectRelative: targetBasename, basename: targetBasename };
}
const projectRelative = toPosixPath(relative(projectRoot, targetFile));
const scopeDirectory = scopeDirectoryForCandidate(projectRoot, candidate);
if (scopeDirectory === null) {
return { projectRelative, basename: targetBasename };
}
return {
projectRelative,
scopeRelative: toPosixPath(relative(scopeDirectory, targetFile)),
basename: targetBasename,
};
}
function scopeDirectoryForCandidate(projectRoot: string, candidate: RuleCandidate): string | null {
if (candidate.isGlobal) {
return null;
}
if (candidate.isSingleFile) {
return dirname(candidate.path);
}
const sourceIndex = candidate.relativePath.indexOf(candidate.source);
if (sourceIndex === -1) {
return projectRoot;
}
const scopeRelativeDirectory = candidate.relativePath.slice(0, sourceIndex).replace(/\/$/, "");
return scopeRelativeDirectory.length === 0 ? projectRoot : join(projectRoot, scopeRelativeDirectory);
}
function toPosixPath(path: string): string {
return path.replaceAll("\\", "/");
}
function storeLastLoad(
state: SessionState,
rules: ReadonlyArray<LoadedRule>,
diagnostics: ReadonlyArray<RuleDiagnostic>,
): void {
state.loadedRules.length = 0;
state.loadedRules.push(...rules);
state.diagnostics.length = 0;
state.diagnostics.push(...diagnostics);
}
function emptyLoadResult(state: SessionState): { rules: LoadedRule[]; diagnostics: RuleDiagnostic[] } {
storeLastLoad(state, [], []);
return { rules: [], diagnostics: [] };
}
function uniqueStrings(values: ReadonlyArray<string>): string[] {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
for (const value of values) {
if (seenValues.has(value)) {
continue;
}
seenValues.add(value);
uniqueValues.push(value);
}
return uniqueValues;
}
@@ -0,0 +1,13 @@
export class UnsupportedRuleSourceError extends Error {
constructor(message: string) {
super(message);
this.name = "UnsupportedRuleSourceError";
}
}
export class RuleFrontmatterParseError extends Error {
constructor(message: string) {
super(message);
this.name = "RuleFrontmatterParseError";
}
}
@@ -0,0 +1,326 @@
import { existsSync, realpathSync, statSync } from "node:fs";
import { homedir } from "node:os";
import { dirname, join, posix, relative, resolve } from "node:path";
import {
GLOBAL_DISTANCE,
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";
interface SingleFileInfo {
path: string;
realPath: string;
}
export interface RuleDiscoveryCache {
scannedRuleFiles: Map<string, ReturnType<typeof scanRuleFiles>>;
singleFileInfo: Map<string, SingleFileInfo | null>;
}
export interface FinderOptions {
/** Project root absolute path (use findProjectRoot to get this). */
projectRoot: string | null;
/** Target file path (used for distance calculation in dynamic injection mode). null for static mode. */
targetFile: string | null;
/** User home directory (default: os.homedir()). Injectable for tests. */
homeDir?: string;
/** Set of disabled sources to omit from discovery. Empty by default. */
disabledSources?: ReadonlySet<string>;
/** Whether to skip user-home rules. Default: false. */
skipUserHome?: boolean;
cache?: RuleDiscoveryCache;
}
interface WalkDirectory {
directory: string;
distance: number;
}
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
}
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());
if (options.projectRoot !== null) {
candidates.push(
...findProjectCandidates(options.projectRoot, options.targetFile, disabledSources, options.cache),
);
}
if (!skipUserHome) {
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
}
return candidates;
}
function findProjectCandidates(
projectRoot: string,
targetFile: string | null,
disabledSources: ReadonlySet<string>,
cache: RuleDiscoveryCache | undefined,
): RuleCandidate[] {
const rootDirectory = resolve(projectRoot);
const walkDirectories = getWalkDirectories(rootDirectory, targetFile);
const candidates: RuleCandidate[] = [];
for (const walkDirectory of walkDirectories) {
for (const [parentDirectory, subDirectory] of PROJECT_RULE_SUBDIRS) {
const source = toProjectRuleSource(parentDirectory, subDirectory);
if (disabledSources.has(source)) {
continue;
}
const ruleDirectory = join(walkDirectory.directory, parentDirectory, subDirectory);
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
candidates.push({
path: scannedFile.path,
realPath: scannedFile.realPath,
source,
distance: targetFile === null ? 0 : walkDirectory.distance,
isGlobal: false,
isSingleFile: false,
relativePath: toRelativePath(rootDirectory, scannedFile.path),
});
}
}
}
for (const walkDirectory of walkDirectories) {
for (const ruleFile of PROJECT_SINGLE_FILES) {
const source = toProjectSingleFileSource(ruleFile);
if (disabledSources.has(source)) {
continue;
}
const filePath = join(walkDirectory.directory, ruleFile);
const fileInfo = singleFileInfoCached(filePath, cache);
if (fileInfo === null) {
continue;
}
candidates.push({
path: fileInfo.path,
realPath: fileInfo.realPath,
source,
distance: targetFile === null ? 0 : walkDirectory.distance,
isGlobal: false,
isSingleFile: true,
relativePath: toRelativePath(rootDirectory, filePath),
});
}
}
return candidates;
}
function findUserHomeCandidates(
homeDirectory: string,
disabledSources: ReadonlySet<string>,
cache: RuleDiscoveryCache | undefined,
): RuleCandidate[] {
const candidates: RuleCandidate[] = [];
for (const ruleSubdir of USER_HOME_RULE_SUBDIRS) {
const source = toUserHomeRuleSource(ruleSubdir);
if (disabledSources.has(source)) {
continue;
}
const ruleDirectory = join(homeDirectory, ruleSubdir);
for (const scannedFile of scanRuleFilesCached(ruleDirectory, cache)) {
candidates.push({
path: scannedFile.path,
realPath: scannedFile.realPath,
source,
distance: GLOBAL_DISTANCE,
isGlobal: true,
isSingleFile: false,
relativePath: toRelativePath(homeDirectory, scannedFile.path),
});
}
}
for (const ruleFile of USER_HOME_SINGLE_FILES) {
const source = toUserHomeSingleFileSource(ruleFile);
if (disabledSources.has(source)) {
continue;
}
const filePath = join(homeDirectory, ruleFile);
const fileInfo = singleFileInfoCached(filePath, cache);
if (fileInfo === null) {
continue;
}
candidates.push({
path: fileInfo.path,
realPath: fileInfo.realPath,
source,
distance: GLOBAL_DISTANCE,
isGlobal: true,
isSingleFile: true,
relativePath: toRelativePath(homeDirectory, filePath),
});
}
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}`);
}
}
@@ -0,0 +1,68 @@
import { truncateBudget, truncateRule } from "./truncator.js";
import type { LoadedRule } from "./types.js";
export interface FormatOptions {
maxRuleChars: number;
maxResultChars: number;
}
type TruncatedRule = {
path: string;
relativePath: string;
body: string;
};
function formatRule(rule: TruncatedRule): string {
return `Instructions from: ${rule.path}\n${rule.body}`;
}
function truncateRules(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): TruncatedRule[] {
const perRuleTruncated = rules.map((rule) => ({
path: rule.path,
relativePath: rule.relativePath,
body: truncateRule(rule.body, { maxChars: options.maxRuleChars, relativePath: rule.relativePath }).body,
}));
const budgetedRules = truncateBudget({
rules: perRuleTruncated.map((rule) => ({ body: rule.body, relativePath: rule.relativePath })),
maxResultChars: options.maxResultChars,
});
const truncatedRules: TruncatedRule[] = [];
for (let index = 0; index < budgetedRules.length; index += 1) {
const sourceRule = perRuleTruncated[index];
const budgetedRule = budgetedRules[index];
if (sourceRule === undefined || budgetedRule === undefined) {
continue;
}
truncatedRules.push({
path: sourceRule.path,
relativePath: budgetedRule.relativePath,
body: budgetedRule.body,
});
}
return truncatedRules;
}
export function formatStaticBlock(rules: ReadonlyArray<LoadedRule>, options: FormatOptions): string {
if (rules.length === 0) {
return "";
}
return `\n\n## Project Instructions\n${truncateRules(rules, options).map(formatRule).join("\n\n")}`;
}
export function formatDynamicBlock(
rules: ReadonlyArray<LoadedRule>,
targetRelativePath: string,
options: FormatOptions,
): string {
if (rules.length === 0) {
return "";
}
return `\n\nAdditional project instructions matched for ${targetRelativePath}:\n\n${truncateRules(rules, options)
.map(formatRule)
.join("\n\n")}`;
}
@@ -0,0 +1,142 @@
import { createHash } from "node:crypto";
import picomatch from "picomatch";
import type { MatchReason, RuleFrontmatter } from "./types.js";
export interface MatcherInput {
frontmatter: RuleFrontmatter;
isSingleFile: boolean;
/** Path bases to try matching against (POSIX-normalized). */
pathBases: { projectRelative: string; scopeRelative?: string; basename: string };
}
export interface MatchResult {
matched: boolean;
reason: MatchReason;
}
interface CompiledPattern {
pattern: string;
isMatch: (path: string) => boolean;
}
interface CompiledPatternSet {
positivePatterns: CompiledPattern[];
negativeMatchers: Array<(path: string) => boolean>;
}
const compiledPatternSets = new Map<string, CompiledPatternSet>();
export function matchRule(input: MatcherInput): MatchResult {
if (input.isSingleFile) {
return { matched: true, reason: "single-file" };
}
if (input.frontmatter.alwaysApply === true) {
return { matched: true, reason: "alwaysApply" };
}
const patterns = normalizeGlobs(input.frontmatter);
if (patterns.length === 0) {
return noMatch();
}
const pathBases = normalizedPathBases(input.pathBases);
const { positivePatterns, negativeMatchers } = compiledPatternSetFor(patterns);
for (const { pattern, isMatch } of positivePatterns) {
for (const pathBase of pathBases) {
if (!isMatch(pathBase)) {
continue;
}
if (isExcluded(pathBase, negativeMatchers)) {
return noMatch();
}
return { matched: true, reason: { kind: "glob", pattern } };
}
}
return noMatch();
}
export function normalizeGlobs(frontmatter: RuleFrontmatter): string[] {
const patterns = [
...normalizePatternList(frontmatter.globs),
...normalizePatternList(frontmatter.paths),
...normalizePatternList(frontmatter.applyTo),
];
return [...new Set(patterns.map(normalizePath))];
}
export function hashContent(body: string): string {
return createHash("sha256").update(body).digest("hex");
}
function normalizePatternList(patterns: string | string[] | undefined): string[] {
if (patterns === undefined) {
return [];
}
return Array.isArray(patterns) ? patterns : [patterns];
}
function normalizePath(path: string): string {
return path.replaceAll("\\", "/");
}
function normalizedPathBases(pathBases: MatcherInput["pathBases"]): string[] {
const normalizedBases = [normalizePath(pathBases.projectRelative)];
if (pathBases.scopeRelative !== undefined) {
normalizedBases.push(normalizePath(pathBases.scopeRelative));
}
normalizedBases.push(normalizePath(pathBases.basename));
return normalizedBases;
}
function compiledPatternSetFor(patterns: ReadonlyArray<string>): CompiledPatternSet {
const cacheKey = JSON.stringify(patterns);
const cached = compiledPatternSets.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const compiled = compilePatternSet(patterns);
compiledPatternSets.set(cacheKey, compiled);
return compiled;
}
function compilePatternSet(patterns: ReadonlyArray<string>): CompiledPatternSet {
const positivePatterns: CompiledPattern[] = [];
const negativeMatchers: Array<(path: string) => boolean> = [];
for (const pattern of patterns) {
if (pattern.startsWith("!")) {
negativeMatchers.push(createGlobMatcher(pattern.slice(1)));
continue;
}
positivePatterns.push({ pattern, isMatch: createGlobMatcher(pattern) });
}
return { positivePatterns, negativeMatchers };
}
function createGlobMatcher(pattern: string): (path: string) => boolean {
return picomatch(normalizePath(pattern), { bash: true, dot: true });
}
function isExcluded(pathBase: string, negativeMatchers: ReadonlyArray<(path: string) => boolean>): boolean {
for (const isMatch of negativeMatchers) {
if (isMatch(pathBase)) {
return true;
}
}
return false;
}
function noMatch(): MatchResult {
return { matched: false, reason: { kind: "no-match" } };
}
@@ -0,0 +1,33 @@
import { SOURCE_PRIORITY } from "./constants.js";
import type { RuleCandidate } from "./types.js";
export function sortCandidates<T extends RuleCandidate>(candidates: ReadonlyArray<T>): T[] {
return candidates
.map((candidate, index) => ({ candidate, index }))
.sort((left, right) => compareCandidates(left.candidate, right.candidate) || left.index - right.index)
.map(({ candidate }) => candidate);
}
export function compareCandidates(a: RuleCandidate, b: RuleCandidate): number {
return (
compareBoolean(a.isGlobal, b.isGlobal) ||
compareNumber(a.distance, b.distance) ||
compareNumber(SOURCE_PRIORITY.get(a.source) ?? Infinity, SOURCE_PRIORITY.get(b.source) ?? Infinity) ||
compareString(a.relativePath, b.relativePath) ||
compareString(a.realPath, b.realPath)
);
}
function compareBoolean(a: boolean, b: boolean): number {
return Number(a) - Number(b);
}
function compareNumber(a: number, b: number): number {
return a - b;
}
function compareString(a: string, b: string): number {
if (a < b) return -1;
if (a > b) return 1;
return 0;
}
@@ -0,0 +1,326 @@
import { RuleFrontmatterParseError } from "./errors.js";
import type { ParsedRule, RuleFrontmatter } from "./types.js";
const FRONTMATTER_OPENING = "---\n";
const FRONTMATTER_OPENING_CRLF = "---\r\n";
/** Parse markdown rule content and extract the supported YAML frontmatter subset. */
export function parseRule(content: string): ParsedRule {
const normalizedContent = stripBom(content);
const openingLength = getOpeningDelimiterLength(normalizedContent);
if (openingLength === 0) {
return { frontmatter: {}, body: normalizedContent };
}
const closingDelimiter = findClosingDelimiter(normalizedContent, openingLength);
if (closingDelimiter === null) {
return {
frontmatter: {},
body: normalizedContent,
diagnostic: "Missing closing frontmatter delimiter",
};
}
const yamlContent = normalizedContent.slice(openingLength, closingDelimiter.start);
const body = normalizedContent.slice(closingDelimiter.bodyStart);
try {
return { frontmatter: parseYamlFrontmatter(yamlContent), body };
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid YAML frontmatter";
return {
frontmatter: {},
body: normalizedContent,
diagnostic: `Malformed frontmatter: ${message}`,
};
}
}
function stripBom(content: string): string {
return content.startsWith("\uFEFF") ? content.slice(1) : content;
}
function getOpeningDelimiterLength(content: string): number {
if (content.startsWith(FRONTMATTER_OPENING_CRLF)) return FRONTMATTER_OPENING_CRLF.length;
if (content.startsWith(FRONTMATTER_OPENING)) return FRONTMATTER_OPENING.length;
return 0;
}
function findClosingDelimiter(content: string, openingLength: number): { start: number; bodyStart: number } | null {
let lineStart = openingLength;
while (lineStart <= content.length) {
const nextNewline = content.indexOf("\n", lineStart);
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
const line = content.slice(lineStart, lineEnd).replace(/\r$/, "");
if (line === "---") {
return {
start: lineStart,
bodyStart: nextNewline === -1 ? content.length : nextNewline + 1,
};
}
if (nextNewline === -1) break;
lineStart = nextNewline + 1;
}
return null;
}
function parseYamlFrontmatter(yamlContent: string): RuleFrontmatter {
const lines = yamlContent.replace(/\r\n/g, "\n").split("\n");
const frontmatter: RuleFrontmatter = {};
const globValues: string[] = [];
let lineIndex = 0;
while (lineIndex < lines.length) {
const rawLine = lines[lineIndex];
if (rawLine === undefined) break;
const line = stripComment(rawLine).trim();
if (line.length === 0) {
lineIndex += 1;
continue;
}
const colonIndex = line.indexOf(":");
if (colonIndex === -1) {
throw new RuleFrontmatterParseError(`Expected key-value pair on line ${lineIndex + 1}`);
}
const key = line.slice(0, colonIndex).trim();
const rawValue = line.slice(colonIndex + 1).trim();
if (key === "description") {
frontmatter.description = parseStringValue(rawValue);
lineIndex += 1;
continue;
}
if (key === "alwaysApply") {
frontmatter.alwaysApply = parseBooleanValue(rawValue, lineIndex + 1);
lineIndex += 1;
continue;
}
if (key === "globs" || key === "paths" || key === "applyTo") {
const parsed = parseGlobValue(rawValue, lines, lineIndex);
for (const glob of parsed.values) {
if (!globValues.includes(glob)) globValues.push(glob);
}
lineIndex += parsed.consumed;
continue;
}
lineIndex += 1;
}
const singleGlob = globValues[0];
if (globValues.length === 1 && singleGlob !== undefined) {
frontmatter.globs = singleGlob;
} else if (globValues.length > 1) {
frontmatter.globs = globValues;
}
return frontmatter;
}
function parseBooleanValue(value: string, lineNumber: number): boolean {
if (value === "true") return true;
if (value === "false") return false;
throw new RuleFrontmatterParseError(`Expected boolean on line ${lineNumber}`);
}
function parseGlobValue(rawValue: string, lines: string[], lineIndex: number): { values: string[]; consumed: number } {
if (rawValue.startsWith("[")) {
return { values: parseInlineArray(rawValue), consumed: 1 };
}
if (rawValue.length === 0) {
return parseMultilineArray(lines, lineIndex);
}
const value = parseStringValue(rawValue);
if (value.includes(",")) {
return {
values: value
.split(",")
.map((item) => item.trim())
.filter(Boolean),
consumed: 1,
};
}
return { values: [value], consumed: 1 };
}
function parseMultilineArray(lines: string[], lineIndex: number): { values: string[]; consumed: number } {
const values: string[] = [];
let consumed = 1;
for (let index = lineIndex + 1; index < lines.length; index += 1) {
const rawLine = lines[index];
if (rawLine === undefined) break;
const lineWithoutComment = stripComment(rawLine);
if (lineWithoutComment.trim().length === 0) {
consumed += 1;
continue;
}
const arrayItem = lineWithoutComment.match(/^\s+-\s*(.*)$/);
if (arrayItem === null) break;
values.push(parseStringValue(arrayItem[1] ?? ""));
consumed += 1;
}
return { values: values.filter(Boolean), consumed };
}
function parseInlineArray(value: string): string[] {
const closingBracketIndex = findClosingBracket(value);
if (closingBracketIndex === -1) {
throw new RuleFrontmatterParseError("Unclosed inline array");
}
const trailing = value.slice(closingBracketIndex + 1).trim();
if (trailing.length > 0) {
throw new RuleFrontmatterParseError("Unexpected content after inline array");
}
const content = value.slice(1, closingBracketIndex).trim();
if (content.length === 0) return [];
return splitCommaSeparated(content).map(parseStringValue).filter(Boolean);
}
function findClosingBracket(value: string): number {
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === undefined) continue;
if (escaped) {
escaped = false;
continue;
}
if (quote !== null && character === "\\") {
escaped = true;
continue;
}
if (character === '"' || character === "'") {
if (quote === null) quote = character;
else if (quote === character) quote = null;
continue;
}
if (quote === null && character === "]") return index;
}
return -1;
}
function splitCommaSeparated(value: string): string[] {
const values: string[] = [];
let current = "";
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character === undefined) continue;
if (escaped) {
current += character;
escaped = false;
continue;
}
if (quote !== null && character === "\\") {
current += character;
escaped = true;
continue;
}
if (character === '"' || character === "'") {
if (quote === null) quote = character;
else if (quote === character) quote = null;
current += character;
continue;
}
if (quote === null && character === ",") {
values.push(current.trim());
current = "";
continue;
}
current += character;
}
if (quote !== null) {
throw new RuleFrontmatterParseError("Unclosed quoted value");
}
values.push(current.trim());
return values.filter(Boolean);
}
function parseStringValue(value: string): string {
if (value.length === 0) return "";
if (value.startsWith('"')) return parseJsonString(value);
if (value.startsWith("'") && value.endsWith("'")) return value.slice(1, -1);
if (value.startsWith("'")) throw new RuleFrontmatterParseError("Unclosed quoted value");
return value;
}
function parseJsonString(value: string): string {
let parsedValue: unknown;
try {
parsedValue = JSON.parse(value);
} catch {
throw new RuleFrontmatterParseError("Invalid JSON-quoted string");
}
if (typeof parsedValue !== "string") {
throw new RuleFrontmatterParseError("Expected JSON-quoted string");
}
return parsedValue;
}
function stripComment(line: string): string {
let quote: string | null = null;
let escaped = false;
for (let index = 0; index < line.length; index += 1) {
const character = line[index];
if (character === undefined) continue;
if (escaped) {
escaped = false;
continue;
}
if (quote !== null && character === "\\") {
escaped = true;
continue;
}
if (character === '"' || character === "'") {
if (quote === null) quote = character;
else if (quote === character) quote = null;
continue;
}
if (quote === null && character === "#") return line.slice(0, index);
}
return line;
}
@@ -0,0 +1,30 @@
import { existsSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { PROJECT_MARKERS } from "./constants.js";
export function findProjectRoot(startPath: string, markers: ReadonlyArray<string> = PROJECT_MARKERS): string | null {
const resolvedStartPath = resolve(startPath);
if (!existsSync(resolvedStartPath)) {
return null;
}
const startStats = statSync(resolvedStartPath);
let currentDirectory = startStats.isDirectory() ? resolvedStartPath : dirname(resolvedStartPath);
const filesystemRoot = resolve("/");
while (true) {
for (const marker of markers) {
if (existsSync(join(currentDirectory, marker))) {
return currentDirectory;
}
}
if (currentDirectory === filesystemRoot) {
return null;
}
currentDirectory = dirname(currentDirectory);
}
}
@@ -0,0 +1,162 @@
import { type Dirent, existsSync, lstatSync, readdirSync, realpathSync, type Stats, statSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import { DEFAULT_MAX_SCAN_FILES, RULE_FILE_EXTENSIONS, SCANNER_EXCLUDED_DIRS } from "./constants.js";
export interface ScanOptions {
rootDir: string;
excludedDirs?: ReadonlyArray<string>;
/** Maximum recursion depth. Default: 10 */
maxDepth?: number;
maxFiles?: number;
}
export interface ScannedFile {
/** Absolute path as encountered (may be a symlink). */
path: string;
/** Real (resolved) path; same as path if not a symlink. */
realPath: string;
}
export function scanRuleFiles(options: ScanOptions): ScannedFile[] {
const rootPath = toAbsolutePath(options.rootDir);
if (!existsSync(rootPath)) {
return [];
}
let rootStats: Stats;
try {
rootStats = statSync(rootPath);
} catch {
return [];
}
if (!rootStats.isDirectory()) {
return [];
}
const results: ScannedFile[] = [];
const visitedDirectories = new Set<string>();
const excludedDirs = new Set(options.excludedDirs ?? SCANNER_EXCLUDED_DIRS);
const maxDepth = options.maxDepth ?? 10;
const maxFiles = normalizeMaxFiles(options.maxFiles);
scanDirectory(rootPath, 0, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
return results;
}
function normalizeMaxFiles(maxFiles: number | undefined): number {
const value = maxFiles ?? DEFAULT_MAX_SCAN_FILES;
if (!Number.isFinite(value) || value < 0) return DEFAULT_MAX_SCAN_FILES;
return Math.floor(value);
}
function toAbsolutePath(filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(filePath);
}
function scanDirectory(
directoryPath: string,
depth: number,
maxDepth: number,
maxFiles: number,
excludedDirs: ReadonlySet<string>,
visitedDirectories: Set<string>,
results: ScannedFile[],
): void {
if (results.length >= maxFiles) {
return;
}
let realDirectoryPath: string;
try {
realDirectoryPath = realpathSync.native(directoryPath);
} catch {
return;
}
if (visitedDirectories.has(realDirectoryPath)) {
return;
}
visitedDirectories.add(realDirectoryPath);
let entries: Dirent[];
try {
entries = readdirSync(directoryPath, { withFileTypes: true }).sort((leftEntry, rightEntry) =>
leftEntry.name.localeCompare(rightEntry.name),
);
} catch {
return;
}
for (const entry of entries) {
if (results.length >= maxFiles) {
return;
}
const entryPath = join(directoryPath, entry.name);
if (entry.isDirectory()) {
if (!excludedDirs.has(entry.name) && depth < maxDepth) {
scanDirectory(entryPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
}
continue;
}
if (entry.isSymbolicLink()) {
scanSymbolicLink(entryPath, entry.name, depth, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
continue;
}
if (entry.isFile() && isRuleFile(entry.name)) {
results.push({ path: entryPath, realPath: resolveRealPath(entryPath) });
}
}
}
function scanSymbolicLink(
linkPath: string,
linkName: string,
depth: number,
maxDepth: number,
maxFiles: number,
excludedDirs: ReadonlySet<string>,
visitedDirectories: Set<string>,
results: ScannedFile[],
): void {
if (results.length >= maxFiles) {
return;
}
let targetStats: Stats;
try {
targetStats = statSync(linkPath);
} catch {
return;
}
if (targetStats.isDirectory()) {
if (!excludedDirs.has(linkName) && depth < maxDepth) {
scanDirectory(linkPath, depth + 1, maxDepth, maxFiles, excludedDirs, visitedDirectories, results);
}
return;
}
if (targetStats.isFile() && isRuleFile(linkName)) {
results.push({ path: linkPath, realPath: resolveRealPath(linkPath) });
}
}
function isRuleFile(fileName: string): boolean {
return RULE_FILE_EXTENSIONS.some((extension) => fileName.endsWith(extension));
}
function resolveRealPath(filePath: string): string {
try {
const realPath = realpathSync.native(filePath);
const fileStats = lstatSync(filePath);
return fileStats.isSymbolicLink() ? realPath : filePath;
} catch {
return filePath;
}
}
@@ -0,0 +1,67 @@
import { TRUNCATION_NOTICE } from "./constants.js";
import type { TruncationResult } from "./types.js";
type BudgetRule = {
body: string;
relativePath: string;
};
type BudgetResult = BudgetRule & {
truncated: boolean;
};
function truncationNotice(relativePath: string): string {
return TRUNCATION_NOTICE.replace("{path}", relativePath);
}
function safeSliceEnd(body: string, end: number): number {
if (end <= 0) {
return 0;
}
const lastCodeUnit = body.charCodeAt(end - 1);
if (lastCodeUnit >= 0xd800 && lastCodeUnit <= 0xdbff) {
return end - 1;
}
return end;
}
export function truncateRule(body: string, options: { maxChars: number; relativePath: string }): TruncationResult {
if (body.length <= options.maxChars) {
return { body, truncated: false, originalLength: body.length };
}
const notice = truncationNotice(options.relativePath);
if (options.maxChars < notice.length) {
return { body: notice, truncated: true, originalLength: body.length };
}
const sliceEnd = safeSliceEnd(body, options.maxChars - notice.length);
return { body: `${body.slice(0, sliceEnd)}${notice}`, truncated: true, originalLength: body.length };
}
export function truncateBudget(input: { rules: ReadonlyArray<BudgetRule>; maxResultChars: number }): BudgetResult[] {
const results: BudgetResult[] = [];
let remainingBudget = input.maxResultChars;
for (const rule of input.rules) {
if (remainingBudget >= rule.body.length) {
results.push({ body: rule.body, truncated: false, relativePath: rule.relativePath });
remainingBudget -= rule.body.length;
continue;
}
const notice = truncationNotice(rule.relativePath);
if (remainingBudget <= notice.length) {
break;
}
const sliceEnd = safeSliceEnd(rule.body, remainingBudget - notice.length);
const body = `${rule.body.slice(0, sliceEnd)}${notice}`;
results.push({ body, truncated: true, relativePath: rule.relativePath });
remainingBudget -= body.length;
}
return results;
}
@@ -0,0 +1,138 @@
/**
* Public types for pi-rules.
*
* These types are stable contracts between modules. The frontmatter type
* mirrors omo's `RuleMetadata` plus Claude (`paths`) and Copilot (`applyTo`)
* aliases that are normalized into `globs` internally.
*/
/**
* YAML frontmatter parsed from a rule markdown file.
* `paths` (Claude alias) and `applyTo` (Copilot alias) are normalized into
* `globs` by the parser before any matcher sees this struct.
*/
export interface RuleFrontmatter {
description?: string;
globs?: string | string[];
paths?: string | string[];
applyTo?: string | string[];
alwaysApply?: boolean;
}
/**
* Result of parsing a rule markdown file.
* `body` excludes the frontmatter delimiters and the YAML payload.
*/
export interface ParsedRule {
frontmatter: RuleFrontmatter;
body: string;
/**
* Diagnostic message if frontmatter parsing failed but the body was salvaged.
* Empty when parsing succeeded.
*/
diagnostic?: string;
}
/**
* A discovered rule file candidate before parsing/matching.
*
* `path` is the absolute path as discovered (possibly via symlink).
* `realPath` is the canonical resolved path used for dedup.
* `source` identifies which discovery source produced this candidate.
*/
export interface RuleCandidate {
path: string;
realPath: string;
source: RuleSource;
/**
* Distance from the target file directory to the directory containing this rule.
* 0 = same directory, 9999 = global/user-home rule.
*/
distance: number;
isGlobal: boolean;
/**
* True when this candidate is a SINGLE-FILE rule like AGENTS.md or
* `.github/copilot-instructions.md` (frontmatter optional, applies always).
*/
isSingleFile: boolean;
/**
* Path relative to project root, POSIX-normalized. Used for matcher and display.
* Empty string for user-home global rules.
*/
relativePath: string;
}
/**
* A fully-loaded rule ready for injection.
*/
export interface LoadedRule extends RuleCandidate {
frontmatter: RuleFrontmatter;
body: string;
contentHash: string;
matchReason: MatchReason;
}
/**
* Source identifier for rule files. Used for deterministic ordering and display.
*/
export type RuleSource =
| ".omo/rules"
| ".claude/rules"
| ".cursor/rules"
| ".github/instructions"
| ".github/copilot-instructions.md"
| "AGENTS.md"
| "CLAUDE.md"
| "CONTEXT.md"
| "~/.omo/rules"
| "~/.opencode/rules"
| "~/.claude/rules"
| "~/.config/opencode/AGENTS.md"
| "~/.claude/CLAUDE.md";
/**
* Why a candidate matched the target file. Surfaced in the injection block so
* the model can attribute its behavior to a specific rule.
*/
export type MatchReason = "alwaysApply" | "single-file" | { kind: "glob"; pattern: string } | { kind: "no-match" };
/**
* Truncation result.
*/
export interface TruncationResult {
body: string;
truncated: boolean;
originalLength: number;
}
/**
* Configuration knobs resolved from env vars and package.json.
*/
export interface PiRulesConfig {
disabled: boolean;
mode: "static" | "dynamic" | "both" | "off";
maxRuleChars: number;
maxResultChars: number;
enabledSources: RuleSource[] | "auto";
}
/**
* Per-session in-memory dedup state.
*
* `staticDedup` keys are `{cwd}::{rulePath}::{contentHash}` strings.
* `dynamicDedup` stores session-scoped `{rulePath}::{contentHash}` strings.
*/
export interface SessionState {
cwd: string | undefined;
staticDedup: Set<string>;
dynamicDedup: Map<string, Set<string>>;
dynamicTargetFingerprints: Map<string, string>;
loadedRules: LoadedRule[];
diagnostics: RuleDiagnostic[];
}
export interface RuleDiagnostic {
severity: "warning" | "error";
source: string;
message: string;
}
@@ -0,0 +1,192 @@
import { existsSync, statSync } from "node:fs";
import { isAbsolute, resolve } from "node:path";
export interface CodexPostToolUseLike {
tool_name: string;
tool_input: unknown;
tool_response: unknown;
}
const COMMAND_TOOL_NAMES = new Set(["bash", "shell_command", "exec_command"]);
const TRACKED_TOOL_NAMES = new Set([
"read",
"read_file",
"mcp__filesystem__read_file",
"mcp__filesystem__read_multiple_files",
"mcp__filesystem__write_file",
"mcp__filesystem__edit_file",
"write",
"edit",
"multiedit",
"multi_edit",
"apply_patch",
"bash",
"shell_command",
"exec_command",
]);
export function extractCodexToolPaths(input: CodexPostToolUseLike, cwd: string): string[] {
const toolName = input.tool_name.toLowerCase();
if (!TRACKED_TOOL_NAMES.has(toolName) || isFailedToolResponse(input.tool_response)) {
return [];
}
const paths = new Set<string>();
const toolInput = isRecord(input.tool_input) ? input.tool_input : {};
addCommonPathFields(paths, toolInput, cwd);
addPatchPayloadPaths(paths, toolInput, cwd);
addPatchRecordPaths(paths, toolInput["files"], cwd);
addPatchRecordPaths(paths, toolInput["changes"], cwd);
if (COMMAND_TOOL_NAMES.has(toolName)) {
const command = stringProperty(toolInput, "command") ?? stringProperty(toolInput, "cmd");
const workdir = stringProperty(toolInput, "workdir") ?? stringProperty(toolInput, "cwd");
addCommandPaths(paths, command, workdir === undefined ? cwd : resolvePath(cwd, workdir));
}
return [...paths];
}
function addCommonPathFields(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
for (const key of ["path", "filePath", "file_path", "target", "targetPath", "target_path"]) {
addPath(paths, input[key], cwd, false);
}
for (const key of ["paths", "filePaths", "file_paths"]) {
addPathArray(paths, input[key], cwd, false);
}
}
function addPatchPayloadPaths(paths: Set<string>, input: Record<string, unknown>, cwd: string): void {
for (const key of ["input", "patch", "command", "cmd"]) {
const value = input[key];
if (typeof value === "string") {
addPatchHeaderPaths(paths, value, cwd);
}
}
}
function addPatchHeaderPaths(paths: Set<string>, patch: string, cwd: string): void {
for (const line of patch.split("\n")) {
for (const prefix of ["*** Add File: ", "*** Update File: ", "*** Move to: "]) {
if (line.startsWith(prefix)) {
addPath(paths, line.slice(prefix.length).trim(), cwd, false);
}
}
}
}
function addPatchRecordPaths(paths: Set<string>, value: unknown, cwd: string): void {
if (!Array.isArray(value)) return;
for (const item of value) {
if (typeof item === "string") {
addPath(paths, item, cwd, false);
continue;
}
if (!isRecord(item)) continue;
addCommonPathFields(paths, item, cwd);
for (const key of ["movePath", "move_path", "to", "from"]) {
addPath(paths, item[key], cwd, false);
}
}
}
function addCommandPaths(paths: Set<string>, command: string | undefined, cwd: string): void {
if (command === undefined) return;
for (const token of tokenizeShell(command)) {
if (token.length === 0 || token.startsWith("-") || token.includes("*")) {
continue;
}
addPath(paths, token, cwd, true);
}
}
function addPathArray(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
if (!Array.isArray(value)) return;
for (const item of value) {
addPath(paths, item, cwd, mustExist);
}
}
function addPath(paths: Set<string>, value: unknown, cwd: string, mustExist: boolean): void {
if (typeof value !== "string" || value.length === 0 || looksLikeUrl(value)) {
return;
}
const path = resolvePath(cwd, value);
if (mustExist && !isExistingFile(path)) {
return;
}
paths.add(path);
}
function resolvePath(cwd: string, filePath: string): string {
return isAbsolute(filePath) ? filePath : resolve(cwd, filePath);
}
function isExistingFile(filePath: string): boolean {
try {
return existsSync(filePath) && statSync(filePath).isFile();
} catch {
return false;
}
}
function looksLikeUrl(value: string): boolean {
return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(value);
}
function stringProperty(value: Record<string, unknown>, key: string): string | undefined {
const property = value[key];
return typeof property === "string" && property.length > 0 ? property : undefined;
}
function tokenizeShell(command: string): string[] {
const tokens: string[] = [];
let current = "";
let quote: "'" | '"' | null = null;
let escaped = false;
for (const character of command) {
if (escaped) {
current += character;
escaped = false;
continue;
}
if (character === "\\") {
escaped = true;
continue;
}
if ((character === "'" || character === '"') && quote === null) {
quote = character;
continue;
}
if (quote === character) {
quote = null;
continue;
}
if (quote === null && /\s/.test(character)) {
if (current.length > 0) {
tokens.push(current);
current = "";
}
continue;
}
current += character;
}
if (current.length > 0) {
tokens.push(current);
}
return tokens;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isFailedToolResponse(value: unknown): boolean {
if (!isRecord(value)) return false;
return (
value["isError"] === true || value["is_error"] === true || value["error"] === true || value["status"] === "error"
);
}