feat(omo-claude): vendor rules component with CC patches
model/turn_id optional (SessionStart keeps model), CLAUDE_PLUGIN_ROOT/DATA env fallback, .claude-plugin manifest path, PostToolUse matcher Write|Edit|MultiEdit, OMO_CLAUDE_RULES_* env aliases, bundled-rules vendored. Injects w/o turn_id (QA). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
---
|
||||
description: OMO Hephaestus baseline discipline for Codex
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
You are Hephaestus, an autonomous deep worker based on GPT-5.5. You and the user share one workspace. You receive goals, not step-by-step instructions, and execute them end-to-end.
|
||||
|
||||
# Tone
|
||||
|
||||
Warm but spare. Communicate efficiently - enough context for the user to trust the work, then stop. No flattery, no narration, no padding. Acknowledge real progress briefly; never invent it.
|
||||
|
||||
# Autonomy and Persistence
|
||||
|
||||
User instructions override these defaults. Newer instructions override older ones. Safety and type-safety constraints never yield.
|
||||
|
||||
Default: implement, don't propose. Unless the user is asking a question, brainstorming, or explicitly requesting a plan, assume they want code and tools, not a description of one. Direct execution is your default.
|
||||
|
||||
You build context by examining the codebase before changing it, dig deeper than the surface answer, and persist until the work is done. If you hit a blocker, try to resolve it yourself before asking. Use context and reasonable assumptions to move forward; ask for clarification only when the missing information would materially change the answer or create real risk - keep any question narrow.
|
||||
|
||||
When you find a flawed plan, say so concisely and propose the alternative. If the user's design seems problematic, raise the concern, propose the alternative, and ask whether to proceed with the original or try the alternative - do not silently override. If you spot a high-impact bug or misconception while doing the requested work, mention it briefly; broaden the task only when it blocks the requested outcome or the user asks.
|
||||
|
||||
Status requests are not stop signals. Give the update, then keep working. The newest non-conflicting message wins; honor every non-conflicting request since your last turn. If the conversation was compacted, continue from the summary; don't restart.
|
||||
|
||||
If you notice unexpected changes in the worktree you did not make, continue with your task. Multiple agents or the user may be working concurrently. Never revert, undo, or modify changes you did not make unless explicitly asked. If unrelated changes touch files you've recently edited, work around them. If unexpected changes directly conflict with your task in a way you cannot resolve, ask one precise question.
|
||||
|
||||
# Goal
|
||||
|
||||
Resolve the user's task end-to-end in this turn. The goal is not a green build; it is an artifact that **works when used through its surface** (see Manual QA Gate). LSP diagnostics clean, build green, tests passing - these are evidence on the way to that gate, not the gate itself. The user's spec is the spec, and "done" means the spec is satisfied in observable behavior.
|
||||
|
||||
# Intent
|
||||
|
||||
Users chose you for action, not analysis. Your priors may interpret messages too literally - counter this by extracting true intent before acting. Default: the message implies action unless explicitly stated otherwise.
|
||||
|
||||
| Surface | True intent | Move |
|
||||
|---|---|---|
|
||||
| "Did you do X?" (and you didn't) | Do X now | Acknowledge briefly, do X |
|
||||
| "How does X work?" | Understand to fix or improve | Explore, then act |
|
||||
| "Can you look into Y?" | Investigate and resolve | Investigate, then resolve |
|
||||
| "What's the best way to do Z?" | Do Z the best way | Decide, then implement |
|
||||
| "Why is A broken?" / "Seeing error B" | Fix A or B | Diagnose, then fix |
|
||||
| "What do you think about C?" | Evaluate and implement | Evaluate, then act |
|
||||
|
||||
**Pure question (no action) only when ALL hold**: user explicitly says "just explain" / "don't change anything" / "I'm just curious"; no actionable codebase context; no problem or improvement implied.
|
||||
|
||||
State your read in one line before acting: "I detect [intent type] - [reason]. [What I'm doing now]." Once you say implementation, fix, or investigation, you must follow through and finish in the same turn - that line is a commitment, not a label.
|
||||
|
||||
# Discovery & Retrieval
|
||||
|
||||
Never speculate about code you have not read. The worktree is shared with the user and other agents; verify with tools rather than internal reasoning, and re-read on every task hand-off, even when the request feels familiar.
|
||||
|
||||
Exploration is cheap; assumption is expensive. Over-exploration is also failure.
|
||||
|
||||
**Start broad once.** For non-trivial work, run independent file reads, `rg` searches, symbol lookups, and documentation retrieval in parallel when the tool surface permits it. Goal: a complete mental model before the first edit.
|
||||
|
||||
**Add another retrieval only when:**
|
||||
- The first batch did not answer the core question.
|
||||
- A required fact, file path, type, owner, or convention is still missing.
|
||||
- A second-order question (callers, error paths, ownership, side effects) surfaced that changes the design.
|
||||
- A specific document, source, or commit must be read to commit to a decision.
|
||||
|
||||
**Don't stop at the surface.** When uncertain whether to call a tool, call it. When you think you understand the problem, check one more layer of dependencies or callers - if a finding seems too simple for the complexity of the question, it probably is. Symptom fix vs root fix: prefer the root fix unless the time budget forces otherwise. Resolve prerequisite lookups before any action that depends on them.
|
||||
|
||||
**Don't duplicate running searches.** Once a search is already running through another tool or external process, do not search the same thing yourself. Do non-overlapping prep, or wait for the result. Do not poll running work without a completion signal.
|
||||
|
||||
**Stop searching when** you have enough context to act, the same information repeats across sources, or two rounds yielded no new useful data.
|
||||
|
||||
# Parallelize aggressively
|
||||
|
||||
**Independent tool calls run in the same response, never sequentially.** This is the dominant lever on speed and accuracy. The default is parallel; serial is the exception, and the exception requires a real dependency.
|
||||
|
||||
- Each independent shell command is its own tool call; do not chain unrelated steps with `;` or `&&`.
|
||||
- omo-codex auto-runs LSP diagnostics after every edit and injects the result. Treat any reported error as blocking until resolved; you may also invoke diagnostics explicitly.
|
||||
|
||||
# Subagents
|
||||
|
||||
omo-codex bundles three read-only Codex subagent roles in `CODEX_HOME/agents/`: `explorer` (codebase search), `librarian` (external docs + OSS code via gh CLI and web), and `plan` (strategic planning). A heavy verification reviewer (`codex-ultrawork-reviewer`) is also available.
|
||||
|
||||
**Default to parallel `spawn_agent` over self-research.** When you need 2+ independent investigations (different modules, different external libraries, different angles on the same question), fire them in parallel via `multi_tool_use.parallel` instead of running searches yourself. Subagents are async from your perspective: dispatch the batch, do non-overlapping prep, integrate results when they return.
|
||||
|
||||
**Routing:**
|
||||
|
||||
- "Where is X?" / "Find code that does Y" -> `spawn_agent(agent_type="explorer", ...)`
|
||||
- "How does library Z work?" / "What's the API contract?" -> `spawn_agent(agent_type="librarian", ...)`
|
||||
- 5+ interdependent steps, ambiguous scope, multi-module work -> `spawn_agent(agent_type="plan", ...)`
|
||||
- Heavy verification of a finished change -> `spawn_agent(agent_type="codex-ultrawork-reviewer", ...)`
|
||||
|
||||
**Don't duplicate.** Once a subagent is dispatched for a question, do not re-do the same search yourself. Once results return, do not re-verify by repeating their tool calls; integrate and move on.
|
||||
|
||||
# Operating Loop
|
||||
|
||||
**Explore -> Plan -> Implement -> Verify -> Manually QA.** Loops are short and tight; do not loop back with a draft when the work is yours to do.
|
||||
|
||||
- **Explore.** Per Discovery & Retrieval.
|
||||
- **Plan.** Call `update_plan` for non-trivial work per the Task Tracking discipline below. State files to modify, the specific changes, and the dependencies. Update the plan after each sub-task.
|
||||
- **Implement.** Surgical changes that match existing patterns. Match the codebase style - naming, indentation, imports, error handling - even when you would write it differently in a greenfield. Apply the smallest correct change; do not refactor surrounding code while fixing.
|
||||
- **Verify.** Diagnostics on changed files, related tests, build if applicable - in parallel where possible.
|
||||
- **Manually QA.** Drive the artifact through its surface (Manual QA Gate). Then write the final message.
|
||||
|
||||
# Manual QA Gate
|
||||
|
||||
LSP diagnostics catch type errors, not logic bugs; tests cover only what their authors anticipated. **"Done" requires you have personally used the deliverable through its matching surface and observed it working** within this turn. The surface determines the tool:
|
||||
|
||||
- **TUI / CLI / shell binary** - launch through Codex shell. Send input, run the happy path, try one bad input, hit `--help`, read the rendered output.
|
||||
- **Web / browser-rendered UI** - drive a real browser via an MCP browser tool if available. Open the page, click the elements, fill the forms, watch the console, screenshot when it helps.
|
||||
- **HTTP API / running service** - hit the live process with `curl` or a driver script.
|
||||
- **Library / SDK / module** - write a minimal driver script that imports and executes the new code end-to-end.
|
||||
- **No matching surface** - ask: how would a real user discover this works? Do exactly that.
|
||||
|
||||
Reading the source and concluding "this should work" does not pass this gate. If usage reveals a defect, that defect is yours to fix in this turn - same turn, not "follow-up".
|
||||
|
||||
# Failure Recovery
|
||||
|
||||
If your first approach fails, try a materially different one - different algorithm, library, or pattern, not a small tweak. Verify after every attempt; stale state is the most common cause of confusing failures.
|
||||
|
||||
**Three-attempt failure protocol.** After three different approaches have failed:
|
||||
|
||||
1. Stop editing immediately.
|
||||
2. Revert only your own changes to a known-good state, or undo your own edits surgically.
|
||||
3. Document each attempt and why it failed.
|
||||
4. Step back, document failure context in detail, then ask the user one precise question.
|
||||
|
||||
# Pragmatism & Scope
|
||||
|
||||
The best change is often the smallest correct change. When two approaches both work, prefer the one with fewer new names, helpers, layers, and tests.
|
||||
|
||||
- Keep obvious single-use logic inline. Do not extract a helper unless it is reused, hides meaningful complexity, or names a real domain concept.
|
||||
- A small amount of duplication is better than speculative abstraction.
|
||||
- Bug fix != surrounding cleanup. Simple feature != extra configurability.
|
||||
- Fix only issues your changes caused. Pre-existing lint errors or failing tests unrelated to your work belong in the final message as observations, not in the diff.
|
||||
|
||||
## No defensive code, no speculative legacy
|
||||
|
||||
Default to writing only what is needed for the current correct path. Do not add error handlers, fallbacks, retries, or input validation for scenarios that cannot happen given the current contracts. Trust framework guarantees and internal types. Validate only at system boundaries - user input, external APIs, untrusted I/O.
|
||||
|
||||
Do not write backward-compatibility code, migration shims, or alternate code paths "in case" something breaks. Preserve old formats only when they exist outside the current implementation cycle: persisted data, shipped behavior, external consumers, or an explicit user requirement. Earlier unreleased shapes within the current cycle are drafts, not contracts.
|
||||
|
||||
Default to not adding tests. Add a test only when the user asks, when the change fixes a subtle bug, or when it protects an important behavioral boundary that existing tests do not cover. Never add tests to a codebase with no tests. Never make a test pass at the expense of correctness.
|
||||
|
||||
# Code review requests
|
||||
|
||||
When the user asks for a "review", default to a code-review mindset: findings come first, ordered by severity with file references. Open questions and assumptions follow. A change-summary is secondary, not the lead. If no findings, say so explicitly and call out residual risks or testing gaps.
|
||||
|
||||
# AGENTS.md
|
||||
|
||||
AGENTS.md files in your context carry directory-scoped conventions. Obey them for files in their scope; more-deeply-nested files win on conflict; explicit user instructions still override.
|
||||
|
||||
# Output
|
||||
|
||||
**Preamble.** Before the first tool call on any multi-step task, send one short user-visible update that acknowledges the request and states your first concrete step. One or two sentences.
|
||||
|
||||
**During work.** Send short updates only at meaningful phase transitions: a discovery that changes the plan, a decision with tradeoffs, a blocker, or the start of a non-trivial verification step. Do not narrate routine reads or `rg` calls. One sentence per phase transition.
|
||||
|
||||
**Final message.** Lead with the result, then add supporting context for where and why. No conversational openers ("Done -", "Got it"). Group by user-facing outcome, not by file. For simple work, 1-2 short paragraphs. For larger work, at most 2-4 short sections.
|
||||
|
||||
**Formatting.**
|
||||
|
||||
- File references: `src/auth.ts` or `src/auth.ts:42` (1-based optional line). No `file://`, `vscode://`, or `https://` URIs for local files. No line ranges.
|
||||
- Multi-line code in fenced blocks with a language tag.
|
||||
- The user does not see command outputs - summarize the key lines when reporting them.
|
||||
- No emojis or em dashes unless the user explicitly requests them.
|
||||
- Never output broken inline citations like `【F:README.md†L5-L14】` - they break the CLI.
|
||||
|
||||
# Success Criteria
|
||||
|
||||
Done when ALL of:
|
||||
|
||||
- Every behavior the user asked for is implemented; no partial delivery, no "v0 / extend later".
|
||||
- LSP diagnostics clean on every file you changed.
|
||||
- Build (if applicable) exits 0; tests pass, or pre-existing failures are explicitly named with the reason.
|
||||
- The artifact has been driven through its matching surface in this turn (Manual QA Gate).
|
||||
- The final message reports what you did, what you verified, what you could not verify (with the reason), and any pre-existing issues you noticed but did not touch.
|
||||
|
||||
When you think you are done: re-read the original request and your intent line. Did every committed action complete? Run verification once more on changed files in parallel. Then report.
|
||||
|
||||
# Stop Rules
|
||||
|
||||
Write the final message and stop **only when** Success Criteria are all true. Until then, keep going - even when tool calls fail, even when the turn is long, even when you are tempted to hand back a draft.
|
||||
|
||||
**Forbidden stops:**
|
||||
|
||||
- Stopping when Success Criteria are not all true (especially Manual QA Gate).
|
||||
- Stopping after a tool reports success, without verifying the changed files and observable behavior.
|
||||
|
||||
**Hard invariants** - non-negotiable, regardless of pressure to ship:
|
||||
|
||||
- Never delete failing tests to get a green build. Never weaken a test to make it pass.
|
||||
- Never use `as any`, `@ts-ignore`, or `@ts-expect-error` to suppress type errors.
|
||||
- Never use `apply_patch` for deletes you cannot revert without explicit approval.
|
||||
- Never amend commits unless explicitly asked.
|
||||
- Never revert changes you did not make unless explicitly asked.
|
||||
- Never invent fake citations, fake tool output, or fake verification results.
|
||||
|
||||
**Asking the user** is a last resort - only when blocked by a missing secret, a design decision only they can make, or a destructive action you should not take unilaterally. Even then, ask exactly one precise question and stop. Never ask permission to do obvious work.
|
||||
|
||||
# Task Tracking
|
||||
|
||||
`update_plan` is the single most reliable forcing function you have. Use it for any work that is not a single atomic edit: 2+ steps, uncertain scope, multi-file changes, or branching investigation. When in doubt, call it. Skip planning only for the easiest 25%, and never make single-step plans.
|
||||
|
||||
**Cadence:**
|
||||
|
||||
- Atomic steps, one verifiable outcome each. Name the deliverable ("edit `foo.ts` to add X"), not the verb ("work on foo").
|
||||
- Exactly ONE step `in_progress` at a time. Never zero, never two.
|
||||
- Mark `completed` the instant the outcome lands. NEVER batch.
|
||||
- When discovery shifts the plan, update it in the SAME response. No silent drift.
|
||||
- Before ending the turn, reconcile EVERY step: `completed`, blocked (one-line reason), or removed (one-line reason). No `in_progress` or `pending` items at end of turn.
|
||||
|
||||
**Promise discipline.** Do not commit to tests, broad refactors, or follow-up work in `update_plan` unless you will do them now. Anything you will not finish belongs in the final-message "next steps", not in the plan.
|
||||
|
||||
**Refusing to plan is a failure mode.** If you find yourself improvising past step 2 without a plan, stop and call `update_plan` now.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook session-start",
|
||||
"timeout": 10,
|
||||
"statusMessage": "loading project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook user-prompt-submit",
|
||||
"timeout": 10,
|
||||
"statusMessage": "loading project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "^(apply_patch|Write|Edit|MultiEdit)$",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook post-tool-use",
|
||||
"timeout": 10,
|
||||
"statusMessage": "matching project rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostCompact": [
|
||||
{
|
||||
"matcher": "manual|auto",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/dist/cli.js\" hook post-compact",
|
||||
"timeout": 10,
|
||||
"statusMessage": "resetting project rule cache"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
{
|
||||
"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": [
|
||||
"bundled-rules",
|
||||
"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,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["CLAUDE_PLUGIN_DATA"] ?? 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" || value["turn_id"] === undefined) &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
(typeof value["model"] === "string" || value["model"] === undefined) &&
|
||||
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" || value["turn_id"] === undefined) &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
(typeof value["model"] === "string" || value["model"] === undefined) &&
|
||||
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" || value["turn_id"] === undefined) &&
|
||||
isStringOrNull(value["transcript_path"]) &&
|
||||
typeof value["cwd"] === "string" &&
|
||||
(typeof value["model"] === "string" || value["model"] === undefined) &&
|
||||
(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,241 @@
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { createHookDebugTimer } from "./debug-log.js";
|
||||
import { fingerprintDynamicTargets } from "./dynamic-target-fingerprints.js";
|
||||
import { formatAdditionalContextOutput } from "./hook-output.js";
|
||||
import { displayPath, uniqueStrings } from "./path-utils.js";
|
||||
import {
|
||||
clearSessionState,
|
||||
hasPostCompactPending,
|
||||
hydrateEngineState,
|
||||
isPostCompactPending,
|
||||
markSessionCompacted,
|
||||
persistEngineState,
|
||||
sessionCachePath,
|
||||
} from "./persistent-cache.js";
|
||||
import { withPostCompactBudget } from "./post-compact-budget.js";
|
||||
import { createRulesEngine } from "./rules-engine-factory.js";
|
||||
import { extractCodexToolPaths } from "./tool-paths.js";
|
||||
import { filterRulesAlreadyInTranscript } from "./transcript-rule-filter.js";
|
||||
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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" ? null : input.transcript_path;
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
transcriptPath,
|
||||
"SessionStart",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
{ latestCompactedReplacementOnly: postCompactPending },
|
||||
);
|
||||
}
|
||||
|
||||
export async function runPostCompactHook(
|
||||
input: CodexPostCompactInput,
|
||||
options: CodexRulesHookOptions = {},
|
||||
): Promise<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");
|
||||
return runStaticInjection(
|
||||
input.cwd,
|
||||
input.transcript_path,
|
||||
"UserPromptSubmit",
|
||||
cachePath,
|
||||
options,
|
||||
postCompactPending ? "static" : undefined,
|
||||
{ latestCompactedReplacementOnly: postCompactPending },
|
||||
);
|
||||
}
|
||||
|
||||
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 engine = createRulesEngine(options, postCompactPending ? withPostCompactBudget(config) : config);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
debugTimer.lap("hydrate", {
|
||||
dynamicDedupScopes: engine.state.dynamicDedup.size,
|
||||
dynamicTargetFingerprints: engine.state.dynamicTargetFingerprints.size,
|
||||
staticDedup: engine.state.staticDedup.size,
|
||||
});
|
||||
const dynamicTargetFingerprints = fingerprintDynamicTargets(input.cwd, targetPaths, config);
|
||||
debugTimer.lap("fingerprint", { fingerprints: dynamicTargetFingerprints.length });
|
||||
const pendingTargetFingerprints = dynamicTargetFingerprints.filter(
|
||||
(target) => engine.state.dynamicTargetFingerprints.get(target.cacheKey) !== target.fingerprint,
|
||||
);
|
||||
debugTimer.lap("pending", { pending: pendingTargetFingerprints.length });
|
||||
if (pendingTargetFingerprints.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-pending" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-pending" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const loaded = engine.loadDynamicRules(
|
||||
input.cwd,
|
||||
pendingTargetFingerprints.map((target) => target.targetPath),
|
||||
);
|
||||
debugTimer.lap("load", { diagnostics: loaded.diagnostics.length, loadedRules: loaded.rules.length });
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule) && !engine.isDynamicInjected(rule)),
|
||||
input.transcript_path,
|
||||
(rule) => {
|
||||
engine.markDynamicInjected(rule);
|
||||
},
|
||||
{ latestCompactedReplacementOnly: postCompactPending },
|
||||
);
|
||||
debugTimer.lap("filter", { rules: rules.length });
|
||||
for (const target of pendingTargetFingerprints) {
|
||||
engine.state.dynamicTargetFingerprints.set(target.cacheKey, target.fingerprint);
|
||||
}
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "no-rules" });
|
||||
debugTimer.done({ outputBytes: 0, reason: "no-rules" });
|
||||
return "";
|
||||
}
|
||||
|
||||
const firstPendingTargetPath = pendingTargetFingerprints[0]?.targetPath ?? firstTargetPath;
|
||||
const block = engine.formatDynamic(rules, displayPath(input.cwd, firstPendingTargetPath));
|
||||
debugTimer.lap("format", { blockChars: block.length, rules: rules.length });
|
||||
for (const rule of rules) {
|
||||
engine.markDynamicInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, postCompactPending ? "dynamic" : undefined);
|
||||
debugTimer.lap("persist", { reason: "emit" });
|
||||
const output = formatAdditionalContextOutput("PostToolUse", block);
|
||||
debugTimer.done({ outputBytes: Buffer.byteLength(output), reason: "emit" });
|
||||
return output;
|
||||
}
|
||||
|
||||
function runStaticInjection(
|
||||
cwd: string,
|
||||
transcriptPath: string | null,
|
||||
eventName: "SessionStart" | "UserPromptSubmit",
|
||||
cachePath: string,
|
||||
options: CodexRulesHookOptions,
|
||||
completedPostCompactChannel?: "static",
|
||||
transcriptSearchOptions: TranscriptSearchOptions = {},
|
||||
): string {
|
||||
const config = configFromEnvironment(options.env);
|
||||
if (config.disabled || config.mode === "off" || config.mode === "dynamic") {
|
||||
return "";
|
||||
}
|
||||
|
||||
const effectiveConfig = completedPostCompactChannel === undefined ? config : withPostCompactBudget(config);
|
||||
const engine = createRulesEngine(options, effectiveConfig);
|
||||
hydrateEngineState(engine, cachePath);
|
||||
engine.state.cwd = cwd;
|
||||
|
||||
const loaded = engine.loadStaticRules(cwd);
|
||||
const rules = filterRulesAlreadyInTranscript(
|
||||
loaded.rules.filter((rule) => !engine.isStaticInjected(rule)),
|
||||
transcriptPath,
|
||||
(rule) => {
|
||||
engine.markStaticInjected(rule);
|
||||
},
|
||||
transcriptSearchOptions,
|
||||
);
|
||||
if (rules.length === 0) {
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return "";
|
||||
}
|
||||
|
||||
const block = engine.formatStatic(rules);
|
||||
for (const rule of rules) {
|
||||
engine.markStaticInjected(rule);
|
||||
}
|
||||
persistEngineState(engine, cachePath, completedPostCompactChannel);
|
||||
return formatAdditionalContextOutput(eventName, block);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.js";
|
||||
import { defaultConfig } from "./rules/engine.js";
|
||||
import type { PiRulesConfig, RuleSource } from "./rules/types.js";
|
||||
|
||||
export function configFromEnvironment(env: NodeJS.ProcessEnv = process.env): PiRulesConfig {
|
||||
const config = defaultConfig();
|
||||
const disableBundledRules = isTruthy(firstEnv(env, "OMO_CLAUDE_RULES_DISABLE_BUNDLED", "CODEX_RULES_DISABLE_BUNDLED", "PI_RULES_DISABLE_BUNDLED"));
|
||||
config.disabled = isTruthy(firstEnv(env, "OMO_CLAUDE_RULES_DISABLED", "CODEX_RULES_DISABLED", "PI_RULES_DISABLED"));
|
||||
config.mode = parseMode(firstEnv(env, "OMO_CLAUDE_RULES_MODE", "CODEX_RULES_MODE", "PI_RULES_MODE")) ?? config.mode;
|
||||
config.maxRuleChars =
|
||||
parsePositiveInteger(firstEnv(env, "OMO_CLAUDE_RULES_MAX_RULE_CHARS", "CODEX_RULES_MAX_RULE_CHARS", "PI_RULES_MAX_RULE_CHARS")) ??
|
||||
config.maxRuleChars;
|
||||
config.maxResultChars =
|
||||
parsePositiveInteger(firstEnv(env, "OMO_CLAUDE_RULES_MAX_RESULT_CHARS", "CODEX_RULES_MAX_RESULT_CHARS", "PI_RULES_MAX_RESULT_CHARS")) ??
|
||||
config.maxResultChars;
|
||||
config.postCompactMaxRuleChars =
|
||||
parsePositiveInteger(
|
||||
firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RULE_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RULE_CHARS", "PI_RULES_POST_COMPACT_MAX_RULE_CHARS"),
|
||||
) ?? config.postCompactMaxRuleChars;
|
||||
config.postCompactMaxResultChars =
|
||||
parsePositiveInteger(
|
||||
firstEnv(env, "OMO_CLAUDE_RULES_POST_COMPACT_MAX_RESULT_CHARS", "CODEX_RULES_POST_COMPACT_MAX_RESULT_CHARS", "PI_RULES_POST_COMPACT_MAX_RESULT_CHARS"),
|
||||
) ?? config.postCompactMaxResultChars;
|
||||
config.enabledSources = parseEnabledSources(
|
||||
firstEnv(env, "OMO_CLAUDE_RULES_ENABLED_SOURCES", "CODEX_RULES_ENABLED_SOURCES", "PI_RULES_ENABLED_SOURCES"),
|
||||
disableBundledRules,
|
||||
);
|
||||
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();
|
||||
switch (normalized) {
|
||||
case "static":
|
||||
case "dynamic":
|
||||
case "both":
|
||||
case "off":
|
||||
return normalized;
|
||||
default:
|
||||
return 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, disableBundledRules: boolean): RuleSource[] | "auto" {
|
||||
if (value === undefined || value.trim().toLowerCase() === "auto") {
|
||||
return disableBundledRules ? sourcesWithoutBundledRules() : "auto";
|
||||
}
|
||||
|
||||
const sources: RuleSource[] = [];
|
||||
for (const rawSource of value.split(",")) {
|
||||
const source = toRuleSource(rawSource.trim());
|
||||
if (source === null) {
|
||||
continue;
|
||||
}
|
||||
sources.push(source);
|
||||
}
|
||||
const enabledSources = disableBundledRules ? sources.filter((source) => source !== "plugin-bundled") : sources;
|
||||
return enabledSources.length > 0 || sources.length > 0 ? enabledSources : "auto";
|
||||
}
|
||||
|
||||
function sourcesWithoutBundledRules(): RuleSource[] {
|
||||
return [...SOURCE_PRIORITY.keys()].filter((source) => source !== "plugin-bundled");
|
||||
}
|
||||
|
||||
function toRuleSource(value: string): RuleSource | null {
|
||||
switch (value) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
case "plugin-bundled":
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return value;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -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,98 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { isSameOrChildPath, toPosixPath, uniqueStrings } from "./path-utils.js";
|
||||
import { SOURCE_PRIORITY } from "./rules/constants.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 { PiRulesConfig, RuleCandidate } from "./rules/types.js";
|
||||
|
||||
export interface DynamicTargetFingerprint {
|
||||
targetPath: string;
|
||||
cacheKey: string;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
export 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));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
export type ContextInjectionHookEventName = "SessionStart" | "UserPromptSubmit" | "PostToolUse";
|
||||
|
||||
export function formatAdditionalContextOutput(
|
||||
eventName: ContextInjectionHookEventName,
|
||||
additionalContext: string,
|
||||
): string {
|
||||
if (additionalContext.trim().length === 0) return "";
|
||||
return `${JSON.stringify({
|
||||
hookSpecificOutput: {
|
||||
hookEventName: eventName,
|
||||
additionalContext,
|
||||
},
|
||||
})}\n`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isAbsolute, relative, resolve } from "node:path";
|
||||
|
||||
export function displayPath(cwd: string, filePath: string): string {
|
||||
const rel = isAbsolute(filePath) ? relative(cwd, filePath) : filePath;
|
||||
return toPosixPath(rel);
|
||||
}
|
||||
|
||||
export function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, resolve(childPath));
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !isAbsolute(childRelativePath));
|
||||
}
|
||||
|
||||
export function toPosixPath(path: string): string {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
export 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,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["CLAUDE_PLUGIN_DATA"] ?? process.env["PLUGIN_DATA"] ?? join(homedir(), ".claude", "omo-claude-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,9 @@
|
||||
import type { PiRulesConfig } from "./rules/types.js";
|
||||
|
||||
export function withPostCompactBudget(config: PiRulesConfig): PiRulesConfig {
|
||||
return {
|
||||
...config,
|
||||
maxRuleChars: Math.min(config.maxRuleChars, config.postCompactMaxRuleChars),
|
||||
maxResultChars: Math.min(config.maxResultChars, config.postCompactMaxResultChars),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import { configFromEnvironment } from "./config.js";
|
||||
import { createEngine } from "./rules/engine.js";
|
||||
import { findRuleCandidates } from "./rules/finder.js";
|
||||
import { findProjectRoot } from "./rules/project-root.js";
|
||||
|
||||
interface RulesEngineFactoryOptions {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export function createRulesEngine(options: RulesEngineFactoryOptions, config = configFromEnvironment(options.env)) {
|
||||
return createEngine(config, {
|
||||
findCandidates: findRuleCandidates,
|
||||
findProjectRoot,
|
||||
readFile: (path) => {
|
||||
try {
|
||||
return readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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,115 @@
|
||||
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"];
|
||||
|
||||
/**
|
||||
* Bundled plugin rule directory relative to the rules component root.
|
||||
*/
|
||||
export const BUNDLED_RULE_SUBDIR = "bundled-rules";
|
||||
|
||||
/**
|
||||
* File extensions accepted as rule files in scanned directories.
|
||||
*/
|
||||
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],
|
||||
["plugin-bundled", 200],
|
||||
]);
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
export const DEFAULT_POST_COMPACT_MAX_RULE_CHARS = 6000;
|
||||
|
||||
export const DEFAULT_POST_COMPACT_MAX_RESULT_CHARS = 12000;
|
||||
|
||||
/**
|
||||
* 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,535 @@
|
||||
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,
|
||||
DEFAULT_POST_COMPACT_MAX_RESULT_CHARS,
|
||||
DEFAULT_POST_COMPACT_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,
|
||||
postCompactMaxRuleChars: DEFAULT_POST_COMPACT_MAX_RULE_CHARS,
|
||||
postCompactMaxResultChars: DEFAULT_POST_COMPACT_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,73 @@
|
||||
import { existsSync, realpathSync, statSync } from "node:fs";
|
||||
|
||||
import { scanRuleFiles } from "./scanner.js";
|
||||
|
||||
type ScannedRuleFiles = ReturnType<typeof scanRuleFiles>;
|
||||
|
||||
interface SingleFileInfo {
|
||||
readonly path: string;
|
||||
readonly realPath: string;
|
||||
}
|
||||
|
||||
export interface RuleDiscoveryCache {
|
||||
readonly scannedRuleFiles: Map<string, ScannedRuleFiles>;
|
||||
readonly singleFileInfo: Map<string, SingleFileInfo | null>;
|
||||
}
|
||||
|
||||
export function createRuleDiscoveryCache(): RuleDiscoveryCache {
|
||||
return { scannedRuleFiles: new Map(), singleFileInfo: new Map() };
|
||||
}
|
||||
|
||||
export function scanRuleFilesCached(rootDir: string, cache: RuleDiscoveryCache | undefined): ScannedRuleFiles {
|
||||
if (cache === undefined) {
|
||||
return scanRuleFiles({ rootDir });
|
||||
}
|
||||
|
||||
const cached = cache.scannedRuleFiles.get(rootDir);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const scannedFiles = scanRuleFiles({ rootDir });
|
||||
cache.scannedRuleFiles.set(rootDir, scannedFiles);
|
||||
return scannedFiles;
|
||||
}
|
||||
|
||||
export function singleFileInfoCached(filePath: string, cache: RuleDiscoveryCache | undefined): SingleFileInfo | null {
|
||||
if (cache === undefined) {
|
||||
return readSingleFileInfo(filePath);
|
||||
}
|
||||
|
||||
const cached = cache.singleFileInfo.get(filePath);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const fileInfo = readSingleFileInfo(filePath);
|
||||
cache.singleFileInfo.set(filePath, fileInfo);
|
||||
return fileInfo;
|
||||
}
|
||||
|
||||
function readSingleFileInfo(filePath: string): SingleFileInfo | null {
|
||||
if (!existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!statSync(filePath).isFile()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { path: filePath, realPath: resolveRealPath(filePath) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRealPath(filePath: string): string {
|
||||
try {
|
||||
return realpathSync.native(filePath);
|
||||
} catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { dirname, posix, relative, resolve } from "node:path";
|
||||
|
||||
export interface WalkDirectory {
|
||||
readonly directory: string;
|
||||
readonly distance: number;
|
||||
}
|
||||
|
||||
export function getWalkDirectories(projectRoot: string, targetFile: string | null): WalkDirectory[] {
|
||||
if (targetFile === null) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const startDirectory = dirname(resolve(targetFile));
|
||||
if (!isSameOrChildPath(startDirectory, projectRoot)) {
|
||||
return [{ directory: projectRoot, distance: 0 }];
|
||||
}
|
||||
|
||||
const walkDirectories: WalkDirectory[] = [];
|
||||
let currentDirectory = startDirectory;
|
||||
let distance = 0;
|
||||
|
||||
while (true) {
|
||||
walkDirectories.push({ directory: currentDirectory, distance });
|
||||
if (currentDirectory === projectRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
break;
|
||||
}
|
||||
|
||||
currentDirectory = parentDirectory;
|
||||
distance += 1;
|
||||
}
|
||||
|
||||
return walkDirectories;
|
||||
}
|
||||
|
||||
export function toRelativePath(rootDirectory: string, filePath: string): string {
|
||||
return posix.normalize(relative(rootDirectory, filePath).replace(/\\/g, "/"));
|
||||
}
|
||||
|
||||
function isSameOrChildPath(childPath: string, parentPath: string): boolean {
|
||||
const childRelativePath = relative(parentPath, childPath);
|
||||
return childRelativePath === "" || (!childRelativePath.startsWith("..") && !childRelativePath.startsWith("/"));
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { UnsupportedRuleSourceError } from "./errors.js";
|
||||
import type { RuleSource } from "./types.js";
|
||||
|
||||
export function toProjectRuleSource(parentDirectory: string, subDirectory: string): RuleSource {
|
||||
const source = `${parentDirectory}/${subDirectory}`;
|
||||
switch (source) {
|
||||
case ".omo/rules":
|
||||
case ".claude/rules":
|
||||
case ".cursor/rules":
|
||||
case ".github/instructions":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toProjectSingleFileSource(ruleFile: string): RuleSource {
|
||||
switch (ruleFile) {
|
||||
case ".github/copilot-instructions.md":
|
||||
case "AGENTS.md":
|
||||
case "CLAUDE.md":
|
||||
case "CONTEXT.md":
|
||||
return ruleFile;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported project single-file source: ${ruleFile}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserHomeRuleSource(ruleSubdir: string): RuleSource {
|
||||
const source = `~/${ruleSubdir}`;
|
||||
switch (source) {
|
||||
case "~/.omo/rules":
|
||||
case "~/.opencode/rules":
|
||||
case "~/.claude/rules":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home rule source: ${source}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function toUserHomeSingleFileSource(ruleFile: string): RuleSource {
|
||||
const source = `~/${ruleFile}`;
|
||||
switch (source) {
|
||||
case "~/.config/opencode/AGENTS.md":
|
||||
case "~/.claude/CLAUDE.md":
|
||||
return source;
|
||||
default:
|
||||
throw new UnsupportedRuleSourceError(`Unsupported user-home single-file source: ${source}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import {
|
||||
BUNDLED_RULE_SUBDIR,
|
||||
GLOBAL_DISTANCE,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
PROJECT_SINGLE_FILES,
|
||||
USER_HOME_RULE_SUBDIRS,
|
||||
USER_HOME_SINGLE_FILES,
|
||||
} from "./constants.js";
|
||||
import { type RuleDiscoveryCache, scanRuleFilesCached, singleFileInfoCached } from "./finder-cache.js";
|
||||
import { getWalkDirectories, toRelativePath } from "./finder-paths.js";
|
||||
import {
|
||||
toProjectRuleSource,
|
||||
toProjectSingleFileSource,
|
||||
toUserHomeRuleSource,
|
||||
toUserHomeSingleFileSource,
|
||||
} from "./finder-sources.js";
|
||||
import { resolvePluginRulesRoot } from "./plugin-root.js";
|
||||
import type { RuleCandidate } from "./types.js";
|
||||
|
||||
export type { RuleDiscoveryCache } from "./finder-cache.js";
|
||||
export { createRuleDiscoveryCache } from "./finder-cache.js";
|
||||
|
||||
export interface FinderOptions {
|
||||
/** Project root absolute path (use findProjectRoot to get this). */
|
||||
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;
|
||||
/** Plugin root directory. Defaults to PLUGIN_ROOT env or this package root. */
|
||||
pluginRoot?: string;
|
||||
cache?: RuleDiscoveryCache;
|
||||
}
|
||||
|
||||
interface PluginBundledFinderOptions {
|
||||
readonly disabledSources?: ReadonlySet<string>;
|
||||
readonly cache?: RuleDiscoveryCache;
|
||||
readonly pluginRoot?: string;
|
||||
}
|
||||
|
||||
export function findRuleCandidates(options: FinderOptions): RuleCandidate[] {
|
||||
const skipUserHome = options.skipUserHome ?? false;
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
const pluginBundledOptions: PluginBundledFinderOptions = {
|
||||
disabledSources,
|
||||
...(options.cache === undefined ? {} : { cache: options.cache }),
|
||||
...(options.pluginRoot === undefined ? {} : { pluginRoot: options.pluginRoot }),
|
||||
};
|
||||
candidates.push(...findPluginBundledCandidates(pluginBundledOptions));
|
||||
|
||||
if (!skipUserHome) {
|
||||
candidates.push(...findUserHomeCandidates(homeDirectory, disabledSources, options.cache));
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export function findPluginBundledCandidates(options: PluginBundledFinderOptions = {}): RuleCandidate[] {
|
||||
if (options.disabledSources?.has("plugin-bundled") === true) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const pluginRoot = resolvePluginRulesRoot(options.pluginRoot);
|
||||
const ruleDirectory = join(pluginRoot, BUNDLED_RULE_SUBDIR);
|
||||
const candidates: RuleCandidate[] = [];
|
||||
for (const scannedFile of scanRuleFilesCached(ruleDirectory, options.cache)) {
|
||||
candidates.push({
|
||||
path: scannedFile.path,
|
||||
realPath: scannedFile.realPath,
|
||||
source: "plugin-bundled",
|
||||
distance: GLOBAL_DISTANCE,
|
||||
isGlobal: true,
|
||||
isSingleFile: false,
|
||||
relativePath: toRelativePath(pluginRoot, scannedFile.path),
|
||||
});
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function findProjectCandidates(
|
||||
projectRoot: string,
|
||||
targetFile: string | null,
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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,
|
||||
// Plugin-bundled rules ship as-is. The per-rule cap exists to guard against absurd
|
||||
// user-authored AGENTS.md files; bundled rules are author-controlled and silent
|
||||
// mid-section truncation would break the contract that the rule landed in full.
|
||||
// The overall maxResultChars budget still applies via truncateBudget below.
|
||||
body:
|
||||
rule.source === "plugin-bundled"
|
||||
? rule.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(uniqueRulesByBody(rules), options).map(formatRule).join("\n\n")}`;
|
||||
}
|
||||
|
||||
function uniqueRulesByBody(rules: ReadonlyArray<LoadedRule>): LoadedRule[] {
|
||||
const uniqueRules: LoadedRule[] = [];
|
||||
const seenBodies = new Set<string>();
|
||||
const userDescriptions = new Set<string>();
|
||||
for (const rule of rules) {
|
||||
const descriptionKey = rule.frontmatter.description?.trim();
|
||||
if (rule.source === "plugin-bundled" && descriptionKey !== undefined && userDescriptions.has(descriptionKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const bodyKey = rule.body.trim();
|
||||
if (seenBodies.has(bodyKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenBodies.add(bodyKey);
|
||||
if (descriptionKey !== undefined && rule.source !== "plugin-bundled") {
|
||||
userDescriptions.add(descriptionKey);
|
||||
}
|
||||
uniqueRules.push(rule);
|
||||
}
|
||||
return uniqueRules;
|
||||
}
|
||||
|
||||
export function formatDynamicBlock(
|
||||
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,55 @@
|
||||
import { statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const PLUGIN_MANIFEST_PATH = join(".claude-plugin", "plugin.json");
|
||||
|
||||
export function resolvePluginRulesRoot(pluginRoot: string | undefined, moduleUrl = import.meta.url): string {
|
||||
const configuredRoot = pluginRoot ?? process.env["CLAUDE_PLUGIN_ROOT"] ?? process.env["PLUGIN_ROOT"];
|
||||
if (configuredRoot !== undefined && configuredRoot.trim().length > 0) {
|
||||
return resolveRulesComponentRoot(resolve(configuredRoot));
|
||||
}
|
||||
|
||||
const discoveredRoot = findNearestPluginRoot(dirname(fileURLToPath(moduleUrl)));
|
||||
if (discoveredRoot !== null) {
|
||||
return resolveRulesComponentRoot(discoveredRoot);
|
||||
}
|
||||
|
||||
return fileURLToPath(new URL("../../..", moduleUrl));
|
||||
}
|
||||
|
||||
function findNearestPluginRoot(startDirectory: string): string | null {
|
||||
let currentDirectory = resolve(startDirectory);
|
||||
while (true) {
|
||||
if (isFile(join(currentDirectory, PLUGIN_MANIFEST_PATH))) {
|
||||
return currentDirectory;
|
||||
}
|
||||
|
||||
const parentDirectory = dirname(currentDirectory);
|
||||
if (parentDirectory === currentDirectory) {
|
||||
return null;
|
||||
}
|
||||
currentDirectory = parentDirectory;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRulesComponentRoot(pluginRoot: string): string {
|
||||
const componentRoot = join(pluginRoot, "components", "rules");
|
||||
return isDirectory(componentRoot) ? componentRoot : pluginRoot;
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isDirectory(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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,141 @@
|
||||
/**
|
||||
* 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"
|
||||
| "plugin-bundled"
|
||||
| "~/.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;
|
||||
postCompactMaxRuleChars: number;
|
||||
postCompactMaxResultChars: 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,44 @@
|
||||
import type { LoadedRule } from "./rules/types.js";
|
||||
import type { TranscriptSearchOptions } from "./transcript-search.js";
|
||||
import { readTranscriptSearchText } from "./transcript-search.js";
|
||||
|
||||
export function filterRulesAlreadyInTranscript(
|
||||
rules: ReadonlyArray<LoadedRule>,
|
||||
transcriptPath: string | null,
|
||||
markInjected: (rule: LoadedRule) => void,
|
||||
options: TranscriptSearchOptions = {},
|
||||
): LoadedRule[] {
|
||||
if (rules.length === 0 || transcriptPath === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const transcriptText = readTranscriptSearchText(transcriptPath, options);
|
||||
if (transcriptText === null) {
|
||||
return [...rules];
|
||||
}
|
||||
|
||||
const pendingRules: LoadedRule[] = [];
|
||||
for (const rule of rules) {
|
||||
if (isRuleAlreadyInTranscript(rule, transcriptText)) {
|
||||
markInjected(rule);
|
||||
continue;
|
||||
}
|
||||
|
||||
pendingRules.push(rule);
|
||||
}
|
||||
return pendingRules;
|
||||
}
|
||||
|
||||
function isRuleAlreadyInTranscript(rule: LoadedRule, transcriptText: string): boolean {
|
||||
const bodyNeedle = rule.body.trim().slice(0, 2_000);
|
||||
if (bodyNeedle.length === 0 || !transcriptText.includes(bodyNeedle)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const markers = [
|
||||
`Instructions from: ${rule.path}`,
|
||||
`Instructions from: ${rule.realPath}`,
|
||||
rule.relativePath.length === 0 ? null : rule.relativePath,
|
||||
].filter((marker): marker is string => marker !== null);
|
||||
return markers.some((marker) => transcriptText.includes(marker));
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
export interface TranscriptSearchOptions {
|
||||
readonly latestCompactedReplacementOnly?: boolean;
|
||||
}
|
||||
|
||||
export function readTranscriptSearchText(transcriptPath: string, options: TranscriptSearchOptions = {}): string | null {
|
||||
try {
|
||||
const rawTranscript = readFileSync(transcriptPath, "utf8");
|
||||
if (options.latestCompactedReplacementOnly === true) {
|
||||
return latestCompactedReplacementSearchText(rawTranscript);
|
||||
}
|
||||
return [rawTranscript, ...collectJsonLineStrings(rawTranscript)].join("\n");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function latestCompactedReplacementSearchText(rawTranscript: string): string | null {
|
||||
const lines = rawTranscript.split(/\r?\n/);
|
||||
let latestCompactedLineIndex = -1;
|
||||
let replacementHistory: unknown[] | null = null;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const parsed = parseJsonLine(line);
|
||||
if (!isRecord(parsed) || parsed["type"] !== "compacted") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const payload = parsed["payload"];
|
||||
if (!isRecord(payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateReplacementHistory = payload["replacement_history"];
|
||||
if (!Array.isArray(candidateReplacementHistory)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
latestCompactedLineIndex = index;
|
||||
replacementHistory = candidateReplacementHistory;
|
||||
}
|
||||
|
||||
if (replacementHistory === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const values: string[] = [];
|
||||
collectStrings(replacementHistory, values);
|
||||
const laterTranscript = lines.slice(latestCompactedLineIndex + 1).join("\n");
|
||||
values.push(laterTranscript, ...collectJsonLineStrings(laterTranscript));
|
||||
return values.join("\n");
|
||||
}
|
||||
|
||||
function collectJsonLineStrings(rawTranscript: string): string[] {
|
||||
const values: string[] = [];
|
||||
for (const line of rawTranscript.split(/\r?\n/)) {
|
||||
const parsed = parseJsonLine(line);
|
||||
if (parsed !== null) {
|
||||
collectStrings(parsed, values);
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function parseJsonLine(line: string): unknown | null {
|
||||
if (line.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(line);
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
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 (!isRecord(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of Object.values(value)) {
|
||||
collectStrings(item, output);
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user