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,13 @@
# Normalize line endings: store LF in git, check out LF on every platform.
# Required so biome's --check passes on Windows (default core.autocrlf=true).
* text=auto eol=lf
# Explicit binary types
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.zip binary
*.tgz binary
*.gz binary
@@ -0,0 +1,12 @@
* @code-yeongyu
.github/workflows/* @code-yeongyu
.github/dependabot.yml @code-yeongyu
package.json @code-yeongyu
package-lock.json @code-yeongyu
LICENSE @code-yeongyu
NOTICE @code-yeongyu
README.md @code-yeongyu
CHANGELOG.md @code-yeongyu
.codex-plugin/plugin.json @code-yeongyu
hooks/hooks.json @code-yeongyu
@@ -0,0 +1,49 @@
name: Bug Report
description: Report broken Codex rule injection or matching behavior
labels: [bug]
body:
- type: markdown
attributes:
value: |
Include the Codex hook payload, hook output, rule file, and plugin version needed to reproduce.
- type: textarea
id: what
attributes:
label: What happened?
description: Include exact output/errors.
validations:
required: true
- type: textarea
id: payload
attributes:
label: Hook payload
description: Paste the minimal SessionStart, UserPromptSubmit, or PostToolUse payload that reproduces the issue.
render: json
validations:
required: false
- type: textarea
id: rule
attributes:
label: Rule file
description: Paste the relevant rule file or frontmatter.
render: markdown
validations:
required: false
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: input
id: version
attributes:
label: codex-rules version
placeholder: 0.1.0
validations:
required: false
@@ -0,0 +1,27 @@
name: Feature Request
description: Propose a Codex rule source, matcher, or hook improvement
labels: [enhancement]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow is blocked or awkward today?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: What should codex-rules do?
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives considered
description: What else could solve this?
validations:
required: false
@@ -0,0 +1,45 @@
{
"name": "main protection",
"target": "branch",
"enforcement": "active",
"conditions": {
"ref_name": {
"include": ["~DEFAULT_BRANCH"],
"exclude": []
}
},
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true
}
},
{
"type": "required_status_checks",
"parameters": {
"strict_required_status_checks_policy": true,
"required_status_checks": [
{ "context": "test (ubuntu-latest · node 20)" },
{ "context": "test (ubuntu-latest · node 22)" },
{ "context": "test (macos-latest · node 20)" },
{ "context": "test (macos-latest · node 22)" }
]
}
}
],
"bypass_actors": [
{
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always"
}
]
}
@@ -0,0 +1,16 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
groups:
dev-dependencies:
dependency-type: development
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
@@ -0,0 +1,20 @@
## Summary
<!-- Brief description, 1-3 bullets -->
-
## Verification
- [ ] `npm run check` (typecheck + biome + build)
- [ ] `npm test` (unit tests)
- [ ] `npm pack --dry-run` (release sanity)
- [ ] Hook smoke-tested locally with `node dist/cli.js hook session-start`
- [ ] Hook smoke-tested locally with `node dist/cli.js hook post-tool-use`
## Codex plugin impact
- [ ] `.codex-plugin/plugin.json` remains valid
- [ ] `hooks/hooks.json` still uses stable Codex hook JSON
- [ ] Session deduplication behavior is covered by tests
- [ ] CHANGELOG entry added for user-facing changes
@@ -0,0 +1,47 @@
name: ci
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
name: test (${{ matrix.os }} · node ${{ matrix.node }})
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
node: ["20", "22"]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node ${{ matrix.node }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
@@ -0,0 +1,51 @@
name: publish
on:
release:
types: [published]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Node 22
uses: actions/setup-node@v6
with:
node-version: "22"
registry-url: https://registry.npmjs.org
cache: npm
- name: Install dependencies
run: npm ci
- name: Check
run: npm run check
- name: Unit tests
run: npm test
- name: Package smoke
run: npm pack --dry-run
- name: Publish to npm
run: |
if [ -z "$NODE_AUTH_TOKEN" ]; then
echo "NODE_AUTH_TOKEN is not configured; skipping npm publish."
exit 0
fi
npm publish --access public --provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
@@ -0,0 +1,6 @@
node_modules/
.DS_Store
*.log
coverage/
.vitest/
*.tgz
@@ -0,0 +1,34 @@
# Repository Conventions
Conventions for human contributors and AI agents working on this repository.
## Style
- Terse technical prose. No emojis in commits, issues, PR comments, or code.
- TypeScript strict mode. No `any`, no `@ts-ignore`, no `@ts-expect-error`, no enums.
- ESM modules with `.js` suffix in runtime import paths.
- Tabs for indentation. Double quotes for strings.
- Tests use vitest with `#given .. #when .. #then` descriptions or plain `// given / // when / // then` body comments.
## Commands
- `npm install` - install dependencies.
- `npm test` - run vitest once.
- `npm run typecheck` - strict TypeScript check.
- `npm run check` - type check, biome, and build.
- `npm pack --dry-run` - release package smoke test.
- `node dist/cli.js hook session-start < fixture.json` - smoke-test static rule injection.
- `node dist/cli.js hook post-tool-use < fixture.json` - smoke-test dynamic rule injection.
## Constraints
- No Bun APIs. Runtime is Node only because Codex launches plugin hooks with Node.
- Keep `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hook behavior covered by tests.
- Keep Codex file path extraction for reads, edits, `apply_patch`, and shell-style tools covered by tests.
- Hook output must use the stable Codex hook JSON contract.
- Do not couple this package back to pi, omo, or senpi internal source paths.
## Don'ts
- No `git add -A` or `git add .`. Stage only the files you changed.
- No `git commit --no-verify`. No force pushes. No history rewriting on shared branches.
@@ -0,0 +1,19 @@
# Changelog
## Unreleased
- Restrict the default `PostToolUse` hook matcher to Codex's canonical `apply_patch` tool name.
- Add opt-in `NODE_DEBUG=codex-rules` phase timing logs for `PostToolUse` debugging.
- Harden dynamic hook coverage for additional-context JSON output, disabled/static modes, failed tool responses, and duplicate suppression.
- Remove redundant apply_patch path scanning and stale tracked-tool constants.
- Use portable Codex hook interpolation and add package smoke coverage for hook entrypoints.
- Cap recursive rule directory scans and run CI on Windows in addition to Ubuntu and macOS.
- Replace the external glob matcher dependency with an internal matcher so clean Codex plugin installs run without `node_modules`.
## 0.1.0 - 2026-05-15
- Port `pi-rules` rule loading, matching, formatting, truncation, and deduplication to a Codex plugin.
- Add `SessionStart`, `UserPromptSubmit`, and `PostToolUse` hooks for static and file-specific context injection.
- Add persistent per-session deduplication under Codex plugin data.
- Add Codex-aware path extraction for read, write, edit, multi-edit, `apply_patch`, and shell command payloads.
- Add tests, CI, release workflow, marketplace metadata, and local install support.
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Yeongyu Kim
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,15 @@
codex-rules
This package implements rule/context loading for Codex plugins.
Its behavior is ported from pi-rules in the pi coding-agent extension ecosystem
and inspired by oh-my-openagent (omo) at https://github.com/code-yeongyu/oh-my-openagent,
including omo's `.omo/rules/` workflow and rules-injector hook architecture.
omo is originally licensed under the Sustainable Use License 1.0.
Yeongyu Kim (https://github.com/code-yeongyu), author of omo, pi-rules, and this
package, licenses the source distributed in this repository under the MIT License.
If any source was ported from omo or pi-rules, that ported source is re-licensed
here under MIT for distribution as a Codex plugin. See LICENSE for terms.
picomatch is by Jon Schlinkert and contributors (https://github.com/micromatch/picomatch).
Distributed under the MIT License.
@@ -0,0 +1,127 @@
# codex-rules
Codex plugin that injects local project rule files into model context through lifecycle hooks.
It ports the `pi-rules` rule injector to Codex:
- `SessionStart` and `UserPromptSubmit` load static project instructions once per session.
- `PostToolUse` watches Codex `apply_patch` by default, then injects matching file-specific rules as additional context.
- `PostCompact` clears the per-session injection cache after manual or automatic compaction so relevant rules can be reintroduced into the compacted conversation.
- Session-level deduplication prevents the same rule from being repeated after it has been injected.
`PostToolUse` output is context-only: it emits `hookSpecificOutput.additionalContext` and does not rewrite tool output.
The runtime has no npm production dependencies, so a clean Codex marketplace copy can run without a follow-up `npm install`.
## Rule Sources
Project-level sources:
- `AGENTS.md`
- `CLAUDE.md`
- `CONTEXT.md`
- `.omo/rules/**/*.md`
- `.claude/rules/**/*.md`
- `.cursor/rules/**/*.md`
- `.github/instructions/**/*.md`
- `.github/copilot-instructions.md`
User-home sources are also supported by the ported engine when available.
Markdown rule files may use frontmatter such as:
```md
---
description: TypeScript defaults
globs: ["**/*.ts", "**/*.tsx"]
alwaysApply: false
---
Prefer strict TypeScript and keep runtime imports ESM-compatible.
```
## Install Locally
From the marketplace workspace:
```bash
codex plugin marketplace add /Users/yeongyu/local-workspaces/codex-plugins
node /Users/yeongyu/local-workspaces/codex-plugins/scripts/install-local.mjs /Users/yeongyu/local-workspaces/codex-plugins
```
The local installer builds the plugin and copies a clean cache entry to:
```text
~/.codex/plugins/cache/code-yeongyu-codex-plugins/omo/0.1.0
```
It also enables:
```toml
[features]
plugins = true
plugin_hooks = true
[plugins."omo@code-yeongyu-codex-plugins"]
enabled = true
```
## Configuration
Use `CODEX_RULES_*` environment variables:
| Variable | Values | Default |
| --- | --- | --- |
| `CODEX_RULES_DISABLED` | `1`, `true`, `yes`, `on` | unset |
| `CODEX_RULES_MODE` | `both`, `static`, `dynamic`, `off` | `both` |
| `CODEX_RULES_MAX_RULE_CHARS` | positive integer | `12000` |
| `CODEX_RULES_MAX_RESULT_CHARS` | positive integer | `40000` |
| `CODEX_RULES_ENABLED_SOURCES` | comma-separated source names | `auto` |
For migration from `pi-rules`, equivalent `PI_RULES_*` variables are accepted as fallbacks.
## Debugging
Enable hook phase timing with `NODE_DEBUG=codex-rules`:
```bash
NODE_DEBUG=codex-rules node dist/cli.js hook post-tool-use < fixture.json
```
Debug lines go to stderr and hook JSON stays on stdout. The log includes `PostToolUse` phases such as `extract`, `fingerprint`, `load`, `persist`, elapsed `ms`, target counts, pending counts, rule counts, and output bytes. It does not log rule bodies or tool response contents.
The default `PostToolUse` hook matcher is intentionally strict: it matches only Codex's canonical `apply_patch` hook tool name. Read tools, MCP filesystem tools, shell commands, and Claude-style `Write`/`Edit` aliases are not registered by default.
## Development
```bash
npm install
npm test
npm run check
npm run typecheck
npm pack --dry-run
```
Performance smoke test:
```bash
npm run bench
```
Benchmark timings depend on the local machine. Use the relative counters and repeat-output checks when comparing runs.
Hook smoke test:
```bash
npm run build
printf '%s\n' '{"session_id":"s","transcript_path":null,"cwd":"/path/to/project","hook_event_name":"SessionStart","model":"gpt-5.5","permission_mode":"default","source":"startup"}' \
| PLUGIN_DATA=/tmp/codex-rules-data node dist/cli.js hook session-start
```
## Privacy
`codex-rules` runs locally. It reads local rule files and Codex hook payloads, writes per-session deduplication state under the Codex plugin data directory, and does not make network requests.
## License
MIT. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
@@ -0,0 +1,48 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.15/schema.json",
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"noDefaultExport": "error",
"noEnum": "error",
"noNonNullAssertion": "error",
"useImportType": "error",
"useConst": "error",
"useNodejsImportProtocol": "off"
},
"complexity": {
"useLiteralKeys": "off"
},
"suspicious": {
"noExplicitAny": "error",
"noTsIgnore": "error",
"noControlCharactersInRegex": "off",
"noEmptyInterface": "off"
}
}
},
"formatter": {
"enabled": true,
"formatWithErrors": false,
"indentStyle": "tab",
"indentWidth": 3,
"lineWidth": 120
},
"files": {
"includes": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts", "!**/node_modules/**/*", "!**/dist/**/*"]
},
"overrides": [
{
"includes": ["vitest.config.ts"],
"linter": {
"rules": {
"style": {
"noDefaultExport": "off"
}
}
}
}
]
}
@@ -0,0 +1,54 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook session-start",
"timeout": 10,
"statusMessage": "loading project rules"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit",
"timeout": 10,
"statusMessage": "loading project rules"
}
]
}
],
"PostToolUse": [
{
"matcher": "^apply_patch$",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
"timeout": 10,
"statusMessage": "matching project rules"
}
]
}
],
"PostCompact": [
{
"matcher": "manual|auto",
"hooks": [
{
"type": "command",
"command": "node \"${PLUGIN_ROOT}/dist/cli.js\" hook post-compact",
"timeout": 10,
"statusMessage": "resetting project rule cache"
}
]
}
]
}
}
@@ -0,0 +1,61 @@
{
"name": "@code-yeongyu/codex-rules",
"version": "0.1.0",
"description": "Codex plugin that injects project rule files into model context through lifecycle hooks.",
"type": "module",
"packageManager": "npm@11.12.1",
"license": "MIT",
"homepage": "https://github.com/code-yeongyu/codex-rules",
"repository": {
"type": "git",
"url": "git+https://github.com/code-yeongyu/codex-rules.git"
},
"bugs": {
"url": "https://github.com/code-yeongyu/codex-rules/issues"
},
"keywords": [
"codex",
"codex-plugin",
"rules",
"hooks",
"agents-md",
"context-injection",
"typescript"
],
"bin": {
"codex-rules": "./dist/cli.js"
},
"files": [
"dist",
"hooks",
"skills",
".codex-plugin",
"LICENSE",
"NOTICE",
"README.md",
"CHANGELOG.md"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"test": "vitest --run",
"test:watch": "vitest",
"bench": "npm run build --silent && node scripts/bench-codex-rules.mjs",
"typecheck": "tsc --noEmit",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"check": "tsc --noEmit && biome check . && npm run build"
},
"dependencies": {
"picomatch": "^4.0.3"
},
"devDependencies": {
"@biomejs/biome": "2.4.15",
"@types/node": "^25.7.0",
"@types/picomatch": "^4.0.0",
"typescript": "^6.0.3",
"vitest": "^4.1.5"
},
"engines": {
"node": ">=20.0.0"
}
}
@@ -0,0 +1,268 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runPostToolUseHook } from "../dist/codex-hook.js";
import { createEngine, defaultConfig } from "../dist/rules/engine.js";
const ITERATIONS = 40;
const WARMUP_ITERATIONS = 5;
const RULE_COUNT = 120;
const DISTINCT_TARGET_COUNT = 80;
const DUPLICATE_TARGET_COUNT = 240;
const args = process.argv.slice(2);
const writeBaselinePath = readOption("--write-baseline");
const comparePath = readOption("--compare");
const result = await runBenchmark();
if (writeBaselinePath !== undefined) {
writeFileSync(writeBaselinePath, `${JSON.stringify(result, null, "\t")}\n`);
}
if (comparePath !== undefined) {
const baseline = JSON.parse(readFileSync(comparePath, "utf8"));
const failures = compareResults(baseline, result);
if (failures.length > 0) {
for (const failure of failures) {
process.stderr.write(`${failure}\n`);
}
process.exitCode = 1;
}
}
process.stdout.write(`${JSON.stringify(result, null, "\t")}\n`);
function readOption(name) {
const index = args.indexOf(name);
if (index === -1) {
return undefined;
}
const value = args[index + 1];
if (value === undefined || value.startsWith("--")) {
throw new Error(`${name} requires a value`);
}
return value;
}
async function runBenchmark() {
const scenarios = [
runScenario("duplicate-targets", duplicateTargets, DUPLICATE_TARGET_COUNT),
runScenario("distinct-targets", distinctTargets, DISTINCT_TARGET_COUNT),
];
return {
commit: gitCommit(),
iterations: ITERATIONS,
warmupIterations: WARMUP_ITERATIONS,
ruleCount: RULE_COUNT,
scenarios,
hookFastPath: await runHookFastPathScenario(),
};
}
async function runHookFastPathScenario() {
const durations = [];
let repeatOutputBytes = 0;
for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) {
const run = await measureHookFastPathRun();
if (iteration >= WARMUP_ITERATIONS) {
durations.push(run.repeatDurationMs);
repeatOutputBytes += run.repeatOutputBytes;
}
}
return {
name: "repeat-post-tool-use",
medianRepeatMs: median(durations),
minRepeatMs: Math.min(...durations),
maxRepeatMs: Math.max(...durations),
repeatOutputBytes,
};
}
async function measureHookFastPathRun() {
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-hook-bench-"));
const pluginData = mkdtempSync(join(tmpdir(), "codex-rules-hook-data-"));
try {
mkdirSync(join(projectRoot, "src"), { recursive: true });
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "bench" }));
writeFileSync(join(projectRoot, "src", "app.ts"), "export const app = true;\n");
for (let index = 0; index < RULE_COUNT; index += 1) {
writeFileSync(join(projectRoot, ".omo", "rules", `rule-${index}.md`), ruleContent(`rule-${index}`));
}
const input = {
session_id: "bench-session",
turn_id: "bench-turn",
transcript_path: null,
cwd: projectRoot,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: join(projectRoot, "src", "app.ts") },
tool_response: { text: "file contents" },
tool_use_id: "bench-call",
};
await runPostToolUseHook(input, {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
const start = process.hrtime.bigint();
const repeatOutput = await runPostToolUseHook(input, {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
return {
repeatDurationMs: Number(process.hrtime.bigint() - start) / 1_000_000,
repeatOutputBytes: Buffer.byteLength(repeatOutput),
};
} finally {
rmSync(projectRoot, { recursive: true, force: true });
rmSync(pluginData, { recursive: true, force: true });
}
}
function runScenario(name, targetFactory, targetCount) {
const durations = [];
let counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 };
for (let iteration = 0; iteration < ITERATIONS + WARMUP_ITERATIONS; iteration += 1) {
const run = measureRun(targetFactory);
if (iteration >= WARMUP_ITERATIONS) {
durations.push(run.durationMs);
counters = addCounters(counters, run.counters);
}
}
return {
name,
targetCount,
medianMs: median(durations),
minMs: Math.min(...durations),
maxMs: Math.max(...durations),
counters,
};
}
function measureRun(targetPaths) {
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-bench-"));
try {
const candidates = makeCandidates(projectRoot);
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
for (const candidate of candidates) {
writeFileSync(candidate.path, "");
}
const counters = { findProjectRoot: 0, findCandidates: 0, readFile: 0 };
const engine = createEngine(defaultConfig(), {
findProjectRoot: () => {
counters.findProjectRoot += 1;
return projectRoot;
},
findCandidates: () => {
counters.findCandidates += 1;
return candidates;
},
readFile: (path) => {
counters.readFile += 1;
return ruleContent(path);
},
});
const generatedTargetPaths = targetPaths(projectRoot);
const start = process.hrtime.bigint();
engine.loadDynamicRules(projectRoot, generatedTargetPaths);
const durationMs = Number(process.hrtime.bigint() - start) / 1_000_000;
return { durationMs, counters };
} finally {
rmSync(projectRoot, { recursive: true, force: true });
}
}
function duplicateTargets(projectRoot) {
const targetPath = join(projectRoot, "src", "app.ts");
return Array.from({ length: DUPLICATE_TARGET_COUNT }, () => targetPath);
}
function distinctTargets(projectRoot) {
return Array.from({ length: DISTINCT_TARGET_COUNT }, (_, index) => join(projectRoot, "src", `file-${index}.ts`));
}
function makeCandidates(projectRoot) {
return Array.from({ length: RULE_COUNT }, (_, index) => ({
path: join(projectRoot, ".omo", "rules", `rule-${index}.md`),
realPath: join(projectRoot, ".omo", "rules", `rule-${index}.md`),
source: ".omo/rules",
distance: 0,
isGlobal: false,
isSingleFile: false,
relativePath: `.omo/rules/rule-${index}.md`,
}));
}
function ruleContent(path) {
return ["---", "globs: **/*.ts", "---", "", `Rule from ${path}`].join("\n");
}
function addCounters(left, right) {
return {
findProjectRoot: left.findProjectRoot + right.findProjectRoot,
findCandidates: left.findCandidates + right.findCandidates,
readFile: left.readFile + right.readFile,
};
}
function median(values) {
const sorted = [...values].sort((left, right) => left - right);
const index = Math.floor(sorted.length / 2);
return sorted[index] ?? 0;
}
function gitCommit() {
try {
return execFileSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).trim();
} catch {
return "unknown";
}
}
function compareResults(baseline, current) {
const failures = [];
for (const scenario of current.scenarios) {
const baselineScenario = baseline.scenarios.find((candidate) => candidate.name === scenario.name);
if (baselineScenario === undefined) {
failures.push(`missing baseline scenario: ${scenario.name}`);
continue;
}
for (const counterName of ["findProjectRoot", "findCandidates", "readFile"]) {
if (scenario.counters[counterName] > baselineScenario.counters[counterName]) {
failures.push(
`${scenario.name}.${counterName} regressed: ${scenario.counters[counterName]} > ${baselineScenario.counters[counterName]}`,
);
}
}
}
if (baseline.hookFastPath === undefined) {
failures.push("missing baseline hookFastPath scenario");
} else {
if (current.hookFastPath.repeatOutputBytes > baseline.hookFastPath.repeatOutputBytes) {
failures.push(
`hookFastPath.repeatOutputBytes regressed: ${current.hookFastPath.repeatOutputBytes} > ${baseline.hookFastPath.repeatOutputBytes}`,
);
}
const maxMedianRepeatMs = baseline.hookFastPath.medianRepeatMs * 1.5;
if (current.hookFastPath.medianRepeatMs > maxMedianRepeatMs) {
failures.push(
`hookFastPath.medianRepeatMs regressed: ${current.hookFastPath.medianRepeatMs} > ${maxMedianRepeatMs}`,
);
}
}
return failures;
}
@@ -0,0 +1,34 @@
---
name: rules
description: Use when the user asks about Codex Rules behavior, injected project rules, supported rule file locations, matching, or environment configuration.
---
# Codex Rules
Codex Rules is automatic once the plugin is enabled. It injects:
- static project instructions on `SessionStart` and `UserPromptSubmit`
- matching file-specific rules after Codex `apply_patch` by default
Dynamic `PostToolUse` output is injected as additional context and is deduplicated per plugin data session. Codex Rules does not rewrite tool output.
Supported project sources:
- `AGENTS.md`
- `CLAUDE.md`
- `CONTEXT.md`
- `.sisyphus/rules/**/*.md`
- `.claude/rules/**/*.md`
- `.cursor/rules/**/*.md`
- `.github/instructions/**/*.md`
- `.github/copilot-instructions.md`
Supported environment knobs:
- `CODEX_RULES_DISABLED=1`
- `CODEX_RULES_MODE=both|static|dynamic|off`
- `CODEX_RULES_MAX_RULE_CHARS=<number>`
- `CODEX_RULES_MAX_RESULT_CHARS=<number>`
- `CODEX_RULES_ENABLED_SOURCES=AGENTS.md,.sisyphus/rules`
The legacy `PI_RULES_*` variables are accepted as fallbacks for users migrating from `pi-rules`.
@@ -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"
);
}
@@ -0,0 +1,99 @@
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CodexPostToolUseInput } from "../src/codex-hook.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
fs.rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(ruleCount: number): { root: string; pluginData: string; targetPath: string } {
const root = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-project-"));
const pluginData = fs.mkdtempSync(path.join(tmpdir(), "codex-rules-hook-perf-data-"));
tempDirectories.push(root, pluginData);
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
fs.mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
fs.mkdirSync(path.join(root, "src"), { recursive: true });
const targetPath = path.join(root, "src", "app.ts");
fs.writeFileSync(targetPath, "export const app = true;\n");
for (let index = 0; index < ruleCount; index += 1) {
fs.writeFileSync(
path.join(root, ".omo", "rules", `rule-${index}.md`),
["---", 'globs: "**/*.ts"', "---", "", `Rule ${index}`].join("\n"),
);
}
return { root, pluginData, targetPath };
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function isProjectRuleRead(filePath: unknown): boolean {
return String(filePath).includes(`${path.sep}.omo${path.sep}rules${path.sep}`);
}
describe("codex rules hook performance", () => {
it("#given unchanged dynamic target #when PostToolUse repeats #then rule files are not reread for fingerprinting", async () => {
// given
const { root, pluginData, targetPath } = makeTempProject(3);
let ruleFileReads = 0;
const originalReadFileSync = fs.readFileSync;
const wrappedReadFileSync = ((...args: Parameters<typeof fs.readFileSync>) => {
if (isProjectRuleRead(args[0])) {
ruleFileReads += 1;
}
return originalReadFileSync(...args);
}) as typeof fs.readFileSync;
fs.readFileSync = wrappedReadFileSync;
syncBuiltinESMExports();
const { runPostToolUseHook } = await import("../src/codex-hook.js");
try {
// when
const firstOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
const firstRunRuleFileReads = ruleFileReads;
ruleFileReads = 0;
const secondOutput = await runPostToolUseHook(postToolUseInput(root, targetPath), {
pluginDataRoot: pluginData,
env: { CODEX_RULES_ENABLED_SOURCES: ".omo/rules" },
});
// then
expect(firstOutput).toContain("Rule 0");
expect(firstRunRuleFileReads).toBe(3);
expect(secondOutput).toBe("");
expect(ruleFileReads).toBe(0);
} finally {
fs.readFileSync = originalReadFileSync;
syncBuiltinESMExports();
}
});
});
@@ -0,0 +1,675 @@
import { spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import {
type CodexPostCompactInput,
type CodexPostToolUseInput,
type CodexSessionStartInput,
runPostCompactHook,
runPostToolUseHook,
runSessionStartHook,
runUserPromptSubmitHook,
} from "../src/codex-hook.js";
type CliResult = {
exitCode: number | null;
stdout: string;
stderr: string;
};
type SessionCache = {
staticDedup?: string[];
dynamicDedup?: Record<string, string[]>;
dynamicTargetFingerprints?: Record<string, string>;
};
const CLI_PATH = fileURLToPath(new URL("../dist/cli.js", import.meta.url));
function runHookCli(input: string, subcommand = "post-tool-use", env: NodeJS.ProcessEnv = {}): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, [CLI_PATH, "hook", subcommand], {
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
child.once("error", reject);
child.once("close", (exitCode) => {
resolve({ exitCode, stdout, stderr });
});
child.stdin.end(input);
});
}
const tempDirectories: string[] = [];
const PROJECT_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: "AGENTS.md,.omo/rules",
};
const RULES_ONLY_ENV = {
CODEX_RULES_ENABLED_SOURCES: ".omo/rules",
};
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeTempProject(): { root: string; pluginData: string } {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-project-"));
const pluginData = mkdtempSync(path.join(tmpdir(), "codex-rules-data-"));
tempDirectories.push(root, pluginData);
writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(path.join(root, "AGENTS.md"), "Always wear safety goggles when refactoring.");
mkdirSync(path.join(root, ".omo", "rules"), { recursive: true });
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
[
"---",
"description: TypeScript",
'globs: ["**/*.ts", "**/*.tsx"]',
"---",
"",
"Prefer strict TypeScript for all source files.",
].join("\n"),
);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
return { root, pluginData };
}
function sessionStartInput(root: string): CodexSessionStartInput {
return {
session_id: "session-1",
transcript_path: null,
cwd: root,
hook_event_name: "SessionStart",
model: "gpt-5.5",
permission_mode: "default",
source: "startup",
};
}
function postCompactInput(root: string): CodexPostCompactInput {
return {
session_id: "session-1",
turn_id: "turn-compact",
transcript_path: null,
cwd: root,
hook_event_name: "PostCompact",
model: "gpt-5.5",
trigger: "manual",
};
}
function userPromptSubmitInput(
root: string,
transcriptPath: string | null = null,
): Parameters<typeof runUserPromptSubmitHook>[0] {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: transcriptPath,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
};
}
function postToolUseInput(root: string, filePath: string): CodexPostToolUseInput {
return {
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "PostToolUse",
model: "gpt-5.5",
permission_mode: "default",
tool_name: "mcp__filesystem__read_file",
tool_input: { path: filePath },
tool_response: { text: "file contents" },
tool_use_id: "call-1",
};
}
function parseHookOutput(output: string): {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
} {
expect(output.trim().length).toBeGreaterThan(0);
return JSON.parse(output) as {
hookSpecificOutput?: {
hookEventName?: string;
additionalContext?: string;
};
};
}
function writeTranscriptWithContext(root: string, ...additionalContexts: string[]): string {
const transcriptPath = path.join(root, "transcript.jsonl");
writeFileSync(
transcriptPath,
`${additionalContexts
.map((additionalContext) => JSON.stringify({ hookSpecificOutput: { additionalContext } }))
.join("\n")}\n`,
);
return transcriptPath;
}
function occurrenceCount(value: string, search: string): number {
return value.split(search).length - 1;
}
function sessionCacheFilePath(pluginData: string, sessionId = "session-1"): string {
return path.join(pluginData, "sessions", `${sessionId}.json`);
}
function readSessionCache(pluginData: string): SessionCache {
return JSON.parse(readFileSync(sessionCacheFilePath(pluginData), "utf8")) as SessionCache;
}
function writeTypeScriptRule(root: string, globExpression: string, body: string): void {
writeFileSync(
path.join(root, ".omo", "rules", "typescript.md"),
["---", "description: TypeScript", `globs: ${globExpression}`, "---", "", body].join("\n"),
);
}
describe("codex rules hooks", () => {
it("#given project rules #when SessionStart runs #then emits static additional context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("SessionStart");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("## Project Instructions");
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Always wear safety goggles");
});
it("#given static context already injected #when UserPromptSubmit runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
await runSessionStartHook(sessionStartInput(root), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runUserPromptSubmitHook(
{
session_id: "session-1",
turn_id: "turn-1",
transcript_path: null,
cwd: root,
hook_event_name: "UserPromptSubmit",
model: "gpt-5.5",
permission_mode: "default",
prompt: "read src/app.ts",
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given resumed session #when SessionStart runs #then it preserves the session cache", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = sessionStartInput(root);
await runSessionStartHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const resumeOutput = await runSessionStartHook(
{ ...input, source: "resume" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const clearOutput = await runSessionStartHook(
{ ...input, source: "clear" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(resumeOutput).toBe("");
expect(parseHookOutput(clearOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
});
it("#given static context remains in transcript but cache is missing #when SessionStart runs #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runSessionStartHook(
{ ...sessionStartInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
expect(readSessionCache(pluginData).staticDedup).toHaveLength(1);
});
it("#given read-file tool result #when PostToolUse runs #then emits matching dynamic rule context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
// The literal "src/app.ts" pins POSIX separators and acts as the Windows
// regression line: prior versions emitted "src\\app.ts" on Windows.
const parsed = parseHookOutput(output);
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(parsed.hookSpecificOutput?.additionalContext).toContain(
"Additional project instructions matched for src/app.ts",
);
expect(parsed.hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
expect(parsed.hookSpecificOutput?.additionalContext ?? "").not.toContain("src\\app.ts");
expect(output).not.toContain("updatedMCPToolOutput");
expect(output).not.toContain("suppressOutput");
expect(output).not.toContain('"decision"');
});
it("#given multiple target paths matching one rule #when PostToolUse runs #then emits dynamic context once for the first target", async () => {
// given
const { root, pluginData } = makeTempProject();
const firstFilePath = path.join(root, "src", "app.ts");
const secondFilePath = path.join(root, "src", "other.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, firstFilePath),
tool_name: "mcp__filesystem__read_multiple_files",
tool_input: { paths: [firstFilePath, secondFilePath, firstFilePath] },
},
{
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
},
);
// then
const parsed = parseHookOutput(output);
const additionalContext = parsed.hookSpecificOutput?.additionalContext ?? "";
expect(parsed.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
expect(additionalContext).toContain("Additional project instructions matched for src/app.ts");
expect(additionalContext).not.toContain("src\\app.ts");
expect(occurrenceCount(additionalContext, "Prefer strict TypeScript")).toBe(1);
});
it("#given dynamic context already injected #when PostToolUse repeats #then emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const cachedState = readSessionCache(pluginData);
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// then
expect(output).toBe("");
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
expect(readSessionCache(pluginData).dynamicTargetFingerprints).toEqual(cachedState.dynamicTargetFingerprints);
});
it("#given dynamic context remains in transcript but cache is missing #when PostToolUse repeats #then it emits no duplicate context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
rmSync(sessionCacheFilePath(pluginData), { force: true });
// when
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
const cachedState = readSessionCache(pluginData);
expect(output).toBe("");
expect(Object.values(cachedState.dynamicDedup ?? {}).flat()).toHaveLength(2);
expect(Object.keys(cachedState.dynamicTargetFingerprints ?? {})).toHaveLength(1);
});
it("#given cached target in one session #when another session reads it #then PostToolUse rechecks independently", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
await runPostToolUseHook(postToolUseInput(root, filePath), { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
// when
const output = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), session_id: "session-2" },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given cached dynamic target #when rule frontmatter changes #then PostToolUse rechecks the target", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
writeTypeScriptRule(root, '"**/*.ts"', "Prefer readonly TypeScript after rule edits.");
// when
const output = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: RULES_ONLY_ENV });
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain(
"Prefer readonly TypeScript after rule edits.",
);
});
it("#given cached dynamic context #when PostCompact runs #then PostToolUse can re-inject after compaction", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const input = postToolUseInput(root, filePath);
const firstOutput = await runPostToolUseHook(input, { pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV });
const firstContext = parseHookOutput(firstOutput).hookSpecificOutput?.additionalContext ?? "";
const transcriptPath = writeTranscriptWithContext(root, firstContext);
expect(
await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
),
).toBe("");
// when
const compactOutput = await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
const output = await runPostToolUseHook(
{ ...input, transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(compactOutput).toBe("");
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given compacted transcript #when static re-injects before dynamic #then dynamic still re-injects", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Prefer strict TypeScript",
);
});
it("#given compacted transcript #when dynamic re-injects before static #then static still re-injects", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
const staticOutput = await runSessionStartHook(sessionStartInput(root), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const dynamicOutput = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
const transcriptPath = writeTranscriptWithContext(
root,
parseHookOutput(staticOutput).hookSpecificOutput?.additionalContext ?? "",
parseHookOutput(dynamicOutput).hookSpecificOutput?.additionalContext ?? "",
);
await runPostCompactHook(
{ ...postCompactInput(root), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData },
);
// when
const dynamicReinjectOutput = await runPostToolUseHook(
{ ...postToolUseInput(root, filePath), transcript_path: transcriptPath },
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
const staticReinjectOutput = await runUserPromptSubmitHook(userPromptSubmitInput(root, transcriptPath), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(parseHookOutput(dynamicReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Prefer strict TypeScript",
);
expect(parseHookOutput(staticReinjectOutput).hookSpecificOutput?.additionalContext).toContain(
"Always wear safety goggles",
);
});
it("#given legacy session cache #when PostToolUse hydrates state #then it accepts the old shape", async () => {
// given
const { root, pluginData } = makeTempProject();
mkdirSync(path.join(pluginData, "sessions"), { recursive: true });
writeFileSync(sessionCacheFilePath(pluginData), `${JSON.stringify({ staticDedup: [], dynamicDedup: {} })}\n`);
// when
const output = await runPostToolUseHook(postToolUseInput(root, path.join(root, "src", "app.ts")), {
pluginDataRoot: pluginData,
env: PROJECT_ONLY_ENV,
});
// then
expect(parseHookOutput(output).hookSpecificOutput?.additionalContext).toContain("Prefer strict TypeScript");
});
it("#given static-only mode #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_MODE: "static",
},
});
// then
expect(output).toBe("");
});
it("#given rules disabled #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(postToolUseInput(root, filePath), {
pluginDataRoot: pluginData,
env: {
...PROJECT_ONLY_ENV,
CODEX_RULES_DISABLED: "true",
},
});
// then
expect(output).toBe("");
});
it("#given failed tool response #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
const filePath = path.join(root, "src", "app.ts");
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, filePath),
tool_response: { is_error: true },
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given tracked tool without path #when PostToolUse runs #then emits no dynamic context", async () => {
// given
const { root, pluginData } = makeTempProject();
// when
const output = await runPostToolUseHook(
{
...postToolUseInput(root, ""),
tool_input: {},
},
{ pluginDataRoot: pluginData, env: PROJECT_ONLY_ENV },
);
// then
expect(output).toBe("");
});
it("#given malformed post-tool-use stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "break;\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given non-object post-tool-use JSON #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = "[]\n";
// when
const result = await runHookCli(input);
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
it("#given debug timing enabled #when PostToolUse hook CLI runs #then phase logs go to stderr only", async () => {
// given
const { root, pluginData } = makeTempProject();
const input = `${JSON.stringify(postToolUseInput(root, path.join(root, "src", "app.ts")))}\n`;
// when
const result = await runHookCli(input, "post-tool-use", {
NODE_DEBUG: "codex-rules",
PLUGIN_DATA: pluginData,
});
// then
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("hookSpecificOutput");
expect(result.stderr).toContain("PostToolUse");
expect(result.stderr).toContain("extract");
expect(result.stderr).toContain("fingerprint");
expect(result.stderr).toContain("load");
expect(result.stderr).toContain("persist");
expect(result.stderr).toContain("ms");
});
it("#given malformed post-compact stdin #when hook CLI runs #then it no-ops without stderr", async () => {
// given
const input = `${JSON.stringify({ hook_event_name: "PostCompact", session_id: "s", turn_id: "t" })}\n`;
// when
const result = await runHookCli(input, "post-compact");
// then
expect(result).toEqual({
exitCode: 0,
stdout: "",
stderr: "",
});
});
});
@@ -0,0 +1,192 @@
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { createEngine, defaultConfig, type EngineDeps } from "../src/rules/engine.js";
import { matchRule as defaultMatchRule } from "../src/rules/matcher.js";
import type { RuleCandidate } from "../src/rules/types.js";
const projectRoot = "/tmp/codex-rules-engine";
function makeCandidate(): RuleCandidate {
return {
path: join(projectRoot, ".omo", "rules", "typescript.md"),
realPath: join(projectRoot, ".omo", "rules", "typescript.md"),
source: ".omo/rules",
distance: 0,
isGlobal: false,
isSingleFile: false,
relativePath: ".omo/rules/typescript.md",
};
}
describe("rule engine dynamic matching", () => {
it("#given duplicate target paths #when loading dynamic rules #then repeated discovery and parsing work is avoided", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
const counters = {
findProjectRoot: 0,
findCandidates: 0,
readFile: 0,
};
const deps = {
findProjectRoot: () => {
counters.findProjectRoot += 1;
return projectRoot;
},
findCandidates: () => {
counters.findCandidates += 1;
return [candidate];
},
readFile: () => {
counters.readFile += 1;
return ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n");
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [targetPath, targetPath, targetPath]);
// then
expect(result.rules).toHaveLength(1);
expect(counters).toEqual({
findProjectRoot: 1,
findCandidates: 1,
readFile: 1,
});
});
it("#given distinct target files in same directory #when loading dynamic rules #then candidate discovery is reused", () => {
// given
const firstTarget = join(projectRoot, "src", "first.ts");
const secondTarget = join(projectRoot, "src", "second.ts");
const thirdTarget = join(projectRoot, "src", "third.ts");
const candidate = makeCandidate();
let findCandidatesCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => {
findCandidatesCalls += 1;
return [candidate];
},
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const result = engine.loadDynamicRules(projectRoot, [firstTarget, secondTarget, thirdTarget]);
// then
expect(result.rules).toHaveLength(1);
expect(findCandidatesCalls).toBe(1);
});
it("#given same rule content and target across loads #when loading dynamic rules repeats #then cached match decision is reused", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(1);
expect(matchCalls).toBe(1);
});
it("#given same rule path changes body #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let body = "Prefer strict TypeScript.";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", "globs: **/*.ts", "---", "", body].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
engine.loadDynamicRules(projectRoot, [targetPath]);
body = "Prefer readonly TypeScript.";
engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(matchCalls).toBe(2);
});
it("#given same rule path changes frontmatter #when loading dynamic rules repeats #then cached match decision invalidates", () => {
// given
const targetPath = join(projectRoot, "src", "app.ts");
const candidate = makeCandidate();
let globs = "**/*.ts";
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () => ["---", `globs: ${globs}`, "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const firstResult = engine.loadDynamicRules(projectRoot, [targetPath]);
globs = "**/*.tsx";
const secondResult = engine.loadDynamicRules(projectRoot, [targetPath]);
// then
expect(firstResult.rules).toHaveLength(1);
expect(secondResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
it("#given same rule and different targets #when loading dynamic rules repeats #then target-specific decisions do not leak", () => {
// given
const sourceTarget = join(projectRoot, "src", "app.ts");
const testTarget = join(projectRoot, "src", "app.test.ts");
const candidate = makeCandidate();
let matchCalls = 0;
const deps = {
findProjectRoot: () => projectRoot,
findCandidates: () => [candidate],
readFile: () =>
["---", 'globs: ["**/*.ts", "!**/*.test.ts"]', "---", "", "Prefer strict TypeScript."].join("\n"),
matchRule: (input) => {
matchCalls += 1;
return defaultMatchRule(input);
},
} satisfies EngineDeps;
const engine = createEngine(defaultConfig(), deps);
// when
const sourceResult = engine.loadDynamicRules(projectRoot, [sourceTarget]);
const testResult = engine.loadDynamicRules(projectRoot, [testTarget]);
// then
expect(sourceResult.rules).toHaveLength(1);
expect(testResult.rules).toHaveLength(0);
expect(matchCalls).toBe(2);
});
});
@@ -0,0 +1,96 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findRuleCandidates } from "../src/rules/finder.js";
import type { RuleCandidate } from "../src/rules/types.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): { projectRoot: string; homeRoot: string; targetPath: string } {
const projectRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-project-"));
const homeRoot = mkdtempSync(join(tmpdir(), "codex-rules-finder-home-"));
tempDirectories.push(projectRoot, homeRoot);
mkdirSync(join(projectRoot, "src", ".omo", "rules"), { recursive: true });
mkdirSync(join(projectRoot, ".omo", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".opencode", "rules"), { recursive: true });
mkdirSync(join(homeRoot, ".config", "opencode"), { recursive: true });
writeFileSync(join(projectRoot, "package.json"), JSON.stringify({ name: "fixture" }));
writeFileSync(join(projectRoot, "AGENTS.md"), "Project rule\n");
writeFileSync(join(projectRoot, "src", ".omo", "rules", "local.md"), "Local rule\n");
writeFileSync(join(projectRoot, ".omo", "rules", "root.md"), "Root rule\n");
writeFileSync(join(homeRoot, ".opencode", "rules", "global.md"), "Global rule\n");
writeFileSync(join(homeRoot, ".config", "opencode", "AGENTS.md"), "Home rule\n");
const targetPath = join(projectRoot, "src", "app.ts");
writeFileSync(targetPath, "export const app = true;\n");
return { projectRoot, homeRoot, targetPath };
}
function candidateSummary(candidate: RuleCandidate): string {
return `${candidate.source}:${candidate.distance}:${candidate.relativePath}`;
}
describe("findRuleCandidates", () => {
it("#given project and user-home rules #when target file is inside project #then candidates keep source distance", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({ projectRoot, targetFile: targetPath, homeDir: homeRoot });
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
"~/.opencode/rules:9999:.opencode/rules/global.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given disabled source #when finding candidates #then matching source is omitted", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
disabledSources: new Set([".omo/rules", "~/.opencode/rules"]),
});
// then
expect(candidates.map(candidateSummary)).toEqual([
"AGENTS.md:1:AGENTS.md",
"~/.config/opencode/AGENTS.md:9999:.config/opencode/AGENTS.md",
]);
});
it("#given skip user home #when finding candidates #then only project rules are returned", () => {
// given
const { projectRoot, homeRoot, targetPath } = makeProject();
// when
const candidates = findRuleCandidates({
projectRoot,
targetFile: targetPath,
homeDir: homeRoot,
skipUserHome: true,
});
// then
expect(candidates.map(candidateSummary)).toEqual([
".omo/rules:0:src/.omo/rules/local.md",
".omo/rules:1:.omo/rules/root.md",
"AGENTS.md:1:AGENTS.md",
]);
});
});
@@ -0,0 +1,206 @@
import { describe, expect, it } from "vitest";
import { matchRule, normalizeGlobs } from "../src/rules/matcher.js";
import type { RuleFrontmatter } from "../src/rules/types.js";
function matchFrontmatter(
frontmatter: RuleFrontmatter,
pathBases: {
projectRelative: string;
scopeRelative?: string;
basename?: string;
},
): ReturnType<typeof matchRule> {
const scopeRelative = pathBases.scopeRelative;
const pathBase = {
projectRelative: pathBases.projectRelative,
basename: pathBases.basename ?? pathBases.projectRelative.split("/").at(-1) ?? pathBases.projectRelative,
...(scopeRelative === undefined ? {} : { scopeRelative }),
};
return matchRule({
frontmatter,
isSingleFile: false,
pathBases: pathBase,
});
}
function matchGlobs(globs: string | string[], projectRelative: string): boolean {
return matchFrontmatter({ globs } satisfies RuleFrontmatter, { projectRelative }).matched;
}
describe("matchRule", () => {
it("#given single-file rule #when matching any target #then it always matches", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchRule({
frontmatter,
isSingleFile: true,
pathBases: { projectRelative: "docs/readme.md", basename: "readme.md" },
});
// then
expect(result).toEqual({ matched: true, reason: "single-file" });
});
it("#given always apply rule #when no glob is configured #then it matches", () => {
// given
const frontmatter = { alwaysApply: true } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: "alwaysApply" });
});
it("#given rule without patterns #when target is checked #then no match is returned", () => {
// given
const frontmatter = {} satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/app.ts" });
// then
expect(result).toEqual({ matched: false, reason: { kind: "no-match" } });
});
it("#given recursive glob #when target is nested #then matches without runtime dependencies", () => {
// given
const globs = "**/*.ts";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given paths alias #when target matches #then glob match is returned", () => {
// given
const frontmatter = { paths: "src/**/*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given applyTo alias #when basename matches #then glob match is returned", () => {
// given
const frontmatter = { applyTo: "*.md" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "docs/README.md" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "*.md" } });
});
it("#given scope-relative target #when scoped path matches #then glob match is returned", () => {
// given
const frontmatter = { globs: "components/**/*.tsx" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, {
projectRelative: "packages/ui/components/button.tsx",
scopeRelative: "components/button.tsx",
});
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "components/**/*.tsx" } });
});
it("#given backslash glob and target #when matching #then paths are normalized", () => {
// given
const frontmatter = { globs: "src\\**\\*.ts" } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src\\features\\app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given multiple positive globs #when later glob matches #then matching pattern is reported", () => {
// given
const frontmatter = { globs: ["docs/**/*.md", "src/**/*.ts"] } satisfies RuleFrontmatter;
// when
const result = matchFrontmatter(frontmatter, { projectRelative: "src/features/app.ts" });
// then
expect(result).toEqual({ matched: true, reason: { kind: "glob", pattern: "src/**/*.ts" } });
});
it("#given negative glob #when target is excluded #then no match is returned", () => {
// given
const globs = ["**/*.ts", "!**/*.test.ts"];
// when
const matched = matchGlobs(globs, "src/features/app.test.ts");
// then
expect(matched).toBe(false);
});
it("#given question-mark glob #when one filename character differs #then target matches", () => {
// given
const globs = "src/app-?.ts";
// when
const matched = matchGlobs(globs, "src/app-a.ts");
// then
expect(matched).toBe(true);
});
it("#given brace glob #when target extension is listed #then matches", () => {
// given
const globs = "src/**/*.{ts,tsx}";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given character class glob #when matching listed extension #then target matches", () => {
// given
const globs = "src/**/*.[tj]s";
// when
const matched = matchGlobs(globs, "src/features/app.ts");
// then
expect(matched).toBe(true);
});
it("#given extglob pattern #when matching allowed extension #then target matches", () => {
// given
const globs = "src/**/*.@(ts|tsx)";
// when
const matched = matchGlobs(globs, "src/features/app.tsx");
// then
expect(matched).toBe(true);
});
it("#given duplicate normalized patterns #when normalizing #then first unique pattern order is kept", () => {
// given
const frontmatter = {
globs: ["src\\**\\*.ts", "src/**/*.ts", "!src/**/*.test.ts"],
paths: "!src/**/*.test.ts",
} satisfies RuleFrontmatter;
// when
const patterns = normalizeGlobs(frontmatter);
// then
expect(patterns).toEqual(["src/**/*.ts", "!src/**/*.test.ts"]);
});
});
@@ -0,0 +1,144 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
type PackageJson = {
readonly type: string;
readonly packageManager: string;
readonly bin: Record<string, string>;
readonly dependencies?: Record<string, unknown>;
};
type PluginJson = {
readonly hooks: string;
};
type HookCommand = {
readonly command: string;
};
type HookEntry = {
readonly matcher?: string;
readonly hooks: readonly HookCommand[];
};
type HooksJson = {
readonly hooks: Record<string, readonly HookEntry[]>;
};
function readPackageJson(path: string): PackageJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPackageJson(parsed)) throw new TypeError(`Invalid package metadata: ${path}`);
return parsed;
}
function readPluginJson(path: string): PluginJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isPluginJson(parsed)) throw new TypeError(`Invalid plugin metadata: ${path}`);
return parsed;
}
function readHooksJson(path: string): HooksJson {
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isHooksJson(parsed)) throw new TypeError(`Invalid hooks metadata: ${path}`);
return parsed;
}
describe("plugin package metadata", () => {
it("#given packaged plugin files #when validating entrypoints #then hook commands use portable plugin root interpolation", () => {
// given
const packageJson = readPackageJson("package.json");
const pluginJson = readPluginJson(".codex-plugin/plugin.json");
const hooksJson = readHooksJson("hooks/hooks.json");
const cliSource = readFileSync("src/cli.ts", "utf8");
// when
const hookConfig = hooksJson.hooks;
const pluginRoot = ["$", "{PLUGIN_ROOT}"].join("");
const commands = [
hookConfig["SessionStart"]?.[0]?.hooks[0]?.command,
hookConfig["UserPromptSubmit"]?.[0]?.hooks[0]?.command,
hookConfig["PostToolUse"]?.[0]?.hooks[0]?.command,
hookConfig["PostCompact"]?.[0]?.hooks[0]?.command,
];
const postToolUseMatcher = hookConfig["PostToolUse"]?.[0]?.matcher ?? "";
// then
expect(packageJson.type).toBe("module");
expect(packageJson.packageManager).toBe("npm@11.12.1");
expect(packageJson.dependencies ?? {}).toEqual({ picomatch: "^4.0.3" });
expect(packageJson.bin["codex-rules"]).toBe("./dist/cli.js");
expect(pluginJson.hooks).toBe("./hooks/hooks.json");
expect(cliSource.startsWith("#!/usr/bin/env node")).toBe(true);
expect(commands).toEqual([
`node "${pluginRoot}/dist/cli.js" hook session-start`,
`node "${pluginRoot}/dist/cli.js" hook user-prompt-submit`,
`node "${pluginRoot}/dist/cli.js" hook post-tool-use`,
`node "${pluginRoot}/dist/cli.js" hook post-compact`,
]);
expect(postToolUseMatcher).toBe("^apply_patch$");
const postToolUseMatcherRegex = new RegExp(postToolUseMatcher);
expect(postToolUseMatcherRegex.test("apply_patch")).toBe(true);
expect(
[
"read",
"Read",
"read_file",
"mcp__filesystem__read_file",
"mcp__filesystem__read_multiple_files",
"mcp__filesystem__write_file",
"mcp__filesystem__edit_file",
"write",
"Write",
"edit",
"Edit",
"multi_edit",
"MultiEdit",
"multiedit",
"exec_command",
"shell_command",
"bash",
"Bash",
].some((toolName) => postToolUseMatcherRegex.test(toolName)),
).toBe(false);
});
});
function isPackageJson(value: unknown): value is PackageJson {
if (!isRecord(value)) return false;
const dependencies = value["dependencies"];
return (
value["type"] === "module" &&
value["packageManager"] === "npm@11.12.1" &&
isStringRecord(value["bin"]) &&
(dependencies === undefined || isRecord(dependencies))
);
}
function isPluginJson(value: unknown): value is PluginJson {
return isRecord(value) && typeof value["hooks"] === "string";
}
function isHooksJson(value: unknown): value is HooksJson {
if (!isRecord(value) || !isRecord(value["hooks"])) return false;
return Object.values(value["hooks"]).every(isHookEntries);
}
function isHookEntries(value: unknown): value is readonly HookEntry[] {
return Array.isArray(value) && value.every(isHookEntry);
}
function isHookEntry(value: unknown): value is HookEntry {
return isRecord(value) && Array.isArray(value["hooks"]) && value["hooks"].every(isHookCommand);
}
function isHookCommand(value: unknown): value is HookCommand {
return isRecord(value) && typeof value["command"] === "string";
}
function isStringRecord(value: unknown): value is Record<string, string> {
return isRecord(value) && Object.values(value).every((item) => typeof item === "string");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
@@ -0,0 +1,63 @@
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { scanRuleFiles } from "../src/rules/scanner.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
describe("scanRuleFiles", () => {
it("#given more rule files than max #when scanning #then returns only capped files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
for (let index = 0; index < 5; index += 1) {
writeFileSync(join(root, `rule-${index}.md`), `Rule ${index}\n`);
}
// when
const files = scanRuleFiles({ rootDir: root, maxFiles: 2 });
// then
expect(files).toHaveLength(2);
});
it("#given rule files and an excluded directory #when scanning #then returns sorted non-excluded files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
mkdirSync(join(root, "dist"), { recursive: true });
writeFileSync(join(root, "beta.md"), "Beta\n");
writeFileSync(join(root, "alpha.md"), "Alpha\n");
writeFileSync(join(root, "dist", "ignored.md"), "Ignored\n");
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "alpha.md"), join(root, "beta.md")]);
});
it("#given symlink loop #when scanning #then traversal terminates without duplicate files", () => {
// given
const root = mkdtempSync(join(tmpdir(), "codex-rules-scanner-"));
tempDirectories.push(root);
const nested = join(root, "nested");
mkdirSync(nested, { recursive: true });
writeFileSync(join(root, "root.md"), "Root\n");
symlinkSync(root, join(nested, "loop"));
// when
const files = scanRuleFiles({ rootDir: root });
// then
expect(files.map((file) => file.path)).toEqual([join(root, "root.md")]);
});
});
@@ -0,0 +1,198 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { type CodexPostToolUseLike, extractCodexToolPaths } from "../src/tool-paths.js";
const tempDirectories: string[] = [];
afterEach(() => {
for (const directory of tempDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function makeProject(): string {
const root = mkdtempSync(path.join(tmpdir(), "codex-rules-paths-"));
tempDirectories.push(root);
mkdirSync(path.join(root, "src"), { recursive: true });
writeFileSync(path.join(root, "src", "app.ts"), "export const app = true;\n");
return root;
}
function postToolUse(input: { toolName: string; toolInput?: unknown; toolResponse?: unknown }): CodexPostToolUseLike {
return {
tool_name: input.toolName,
tool_input: input.toolInput ?? {},
tool_response: input.toolResponse ?? { text: "ok" },
};
}
describe("extractCodexToolPaths", () => {
it("#given filesystem read payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_file",
toolInput: { path: "src/app.ts" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch payload #when extracting #then returns patched file paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Update File: src/app.ts",
"@@",
"+export const changed = true;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given apply_patch add update and move payload #when extracting #then returns each target once", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "apply_patch",
toolInput: {
command: [
"*** Begin Patch",
"*** Add File: src/new.ts",
"+export const created = true;",
"*** Update File: src/app.ts",
"*** Move to: src/moved.ts",
"@@",
"-export const app = true;",
"+export const moved = true;",
"*** Update File: src/moved.ts",
"@@",
"-export const moved = true;",
"+export const moved = false;",
"*** End Patch",
].join("\n"),
},
}),
root,
);
// then
expect(paths).toEqual([
path.join(root, "src", "new.ts"),
path.join(root, "src", "app.ts"),
path.join(root, "src", "moved.ts"),
]);
});
it("#given mcp write-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__write_file",
toolInput: { path: "src/app.ts", content: "export const app = true;\n" },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp edit-file payload #when extracting #then returns resolved path", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__edit_file",
toolInput: { path: "src/app.ts", edits: [] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given mcp read-multiple-files payload #when extracting #then returns all resolved paths", () => {
// given
const root = makeProject();
writeFileSync(path.join(root, "src", "other.ts"), "export const other = true;\n");
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "mcp__filesystem__read_multiple_files",
toolInput: { paths: ["src/app.ts", "src/other.ts"] },
}),
root,
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts"), path.join(root, "src", "other.ts")]);
});
it("#given shell command payload #when extracting #then returns only existing file tokens", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "exec_command",
toolInput: { cmd: "sed -n '1,80p' src/app.ts src/missing.ts", workdir: root },
}),
"/tmp",
);
// then
expect(paths).toEqual([path.join(root, "src", "app.ts")]);
});
it("#given failed tracked tool payload #when extracting #then returns no paths", () => {
// given
const root = makeProject();
// when
const paths = extractCodexToolPaths(
postToolUse({
toolName: "read",
toolInput: { path: "src/app.ts" },
toolResponse: { is_error: true },
}),
root,
);
// then
expect(paths).toEqual([]);
});
});
@@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": false,
"declaration": true,
"outDir": "dist",
"rootDir": "src",
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["test/**/*"]
}
@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"lib": ["ES2022"],
"strict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"noPropertyAccessFromIndexSignature": true,
"verbatimModuleSyntax": true,
"noImplicitOverride": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"useDefineForClassFields": false,
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*", "test/**/*"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
pool: "threads",
},
});