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"; import type { TranscriptSearchOptions } from "./transcript-search.js"; import { readTranscriptSearchText } from "./transcript-search.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 { 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" ? null : input.transcript_path; return runStaticInjection( input.cwd, transcriptPath, "SessionStart", cachePath, options, postCompactPending ? "static" : undefined, { latestCompactedReplacementOnly: postCompactPending }, ); } export async function runPostCompactHook( input: CodexPostCompactInput, options: CodexRulesHookOptions = {}, ): Promise { markSessionCompacted(sessionCachePath(input.session_id, options.pluginDataRoot)); return ""; } export async function runUserPromptSubmitHook( input: CodexUserPromptSubmitInput, options: CodexRulesHookOptions = {}, ): Promise { const cachePath = sessionCachePath(input.session_id, options.pluginDataRoot); const postCompactPending = isPostCompactPending(cachePath, "static"); return runStaticInjection( input.cwd, input.transcript_path, "UserPromptSubmit", cachePath, options, postCompactPending ? "static" : undefined, { latestCompactedReplacementOnly: postCompactPending }, ); } export async function runPostToolUseHook( input: CodexPostToolUseInput, options: CodexRulesHookOptions = {}, ): Promise { 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 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)), input.transcript_path, (rule) => { engine.markDynamicInjected(rule); }, { latestCompactedReplacementOnly: postCompactPending }, ); 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", transcriptSearchOptions: TranscriptSearchOptions = {}, ): 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); }, transcriptSearchOptions, ); 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, transcriptPath: string | null, markInjected: (rule: LoadedRule) => void, options: TranscriptSearchOptions = {}, ): LoadedRule[] { if (rules.length === 0 || transcriptPath === null) { return [...rules]; } const transcriptText = readTranscriptSearchText(transcriptPath, options); 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 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, 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; cache: ReturnType; } = { 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 | 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[] { const uniqueValues: string[] = []; const seenValues = new Set(); 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("\\", "/"); }