diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..3fc3bf6
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.11
+
+ - run: bun install --frozen-lockfile
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Typecheck
+ run: bunx tsc --noEmit
+
+ - name: Test
+ run: bun run test:run
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..a1e733e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,44 @@
+name: Release
+
+on:
+ push:
+ tags: ['v*']
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
+
+ - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
+ with:
+ bun-version: 1.3.11
+
+ - run: bun install --frozen-lockfile
+
+ - name: Lint
+ run: bun run lint
+
+ - name: Typecheck
+ run: bunx tsc --noEmit
+
+ - name: Test
+ run: bun run test:run
+
+ - name: Build
+ run: bun run build
+
+ - name: Verify package contents
+ run: bun pm pack --dry-run
+
+ - name: Publish to npm
+ run: bun publish --access public
+ env:
+ NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@26e8ad27a09a225049a7075d7ec1caa2df6ff332 # v2
+ with:
+ generate_release_notes: true
diff --git a/.gitignore b/.gitignore
index 709b0e8..7cb2afb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -53,3 +53,10 @@ temp/
# Agent planning
.opencode/plans
+
+# Desloppify runtime artifacts
+.desloppify/external_review_sessions/
+.desloppify/review_packets/
+.desloppify/subagents/
+.desloppify/*.bak
+.desloppify/review_packet_blind.json
diff --git a/.npmignore b/.npmignore
new file mode 100644
index 0000000..f936c19
--- /dev/null
+++ b/.npmignore
@@ -0,0 +1,14 @@
+# Exclusions handled by package.json "files" negation patterns
+# This file is kept for documentation purposes
+
+# Test files - excluded via !src/**/*.test.ts, !tui/**/*.test.ts patterns
+# *.test.ts
+# *.test.tsx
+# *.spec.ts
+# *.spec.tsx
+
+# Test fixtures - excluded via !src/test-fixtures.ts
+# src/test-fixtures.ts
+
+# Compile-time type checks - excluded via !src/api-surface.typecheck.ts
+# src/api-surface.typecheck.ts
diff --git a/.opencode/rules/10-runtime-consistency.md b/.opencode/rules/10-runtime-consistency.md
new file mode 100644
index 0000000..fd68c5e
--- /dev/null
+++ b/.opencode/rules/10-runtime-consistency.md
@@ -0,0 +1,13 @@
+---
+globs:
+ - 'src/runtime.ts'
+ - 'src/utils.ts'
+ - 'src/message-context.ts'
+ - 'src/mcp-tools.ts'
+---
+
+# Runtime Consistency
+
+- Use shared message-context helpers for prompt and part extraction. Do not duplicate extraction loops in runtime hooks.
+- Keep CI/env boolean detection on one parser path (`parseEnvBoolean` / `isTruthyEnvValue`) across all provider checks.
+- Keep warning channels consistent: user-actionable rule-file problems may use `console.warn`; internal operational failures should go through debug logging.
diff --git a/.opencode/rules/11-readme-and-doc-sync.md b/.opencode/rules/11-readme-and-doc-sync.md
new file mode 100644
index 0000000..19d2386
--- /dev/null
+++ b/.opencode/rules/11-readme-and-doc-sync.md
@@ -0,0 +1,17 @@
+---
+globs:
+ - 'README.md'
+ - 'docs/**/*.md'
+keywords:
+ - 'readme'
+ - 'architecture'
+ - 'project structure'
+ - 'documentation'
+match: any
+---
+
+# README and Documentation Sync
+
+- When adding, removing, or renaming production modules, update the README Project Structure section in the same change.
+- Keep architecture docs aligned with current hook/runtime behavior and supported rule filters.
+- Remove stale references to deprecated behavior as part of the same PR that changes behavior.
diff --git a/.opencode/rules/12-hotspot-guardrails.md b/.opencode/rules/12-hotspot-guardrails.md
new file mode 100644
index 0000000..e7440c7
--- /dev/null
+++ b/.opencode/rules/12-hotspot-guardrails.md
@@ -0,0 +1,12 @@
+---
+globs:
+ - 'src/utils.ts'
+ - 'src/runtime.ts'
+ - 'src/index.test.ts'
+---
+
+# Hotspot Guardrails
+
+- In `src/utils.ts`, do not add new unrelated responsibilities. Prefer splitting by domain (discovery, metadata, matching, message paths).
+- In `src/runtime.ts`, extract shared helpers before adding additional inline transformation logic.
+- In `src/index.test.ts`, prefer creating or expanding module-focused test files instead of growing the monolithic suite.
diff --git a/README.md b/README.md
index 7497b3e..f2a2be2 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,10 @@ This approach allows you to dynamically include rules automatically like style g
guidance on specific actions, etc. Unlike skills, which are called on by the agent, rules use a simple matching
approach.
+> [!NOTE]
+> The name `opencode-rules` is to be concise about what this plugin does. It is in no way affiliated with Anomaly Co. or
+> the official OpenCode project.
+
## Features
- **Dual-format support**: Load rules from both `.md` and `.mdc` files
@@ -33,11 +37,19 @@ approach.
- **Zero-configuration**: Works out of the box with XDG Base Directory specification
- **TypeScript-first**: Built with TypeScript for type safety and developer experience
- **Performance optimized**: Efficient file discovery and minimal startup overhead
+- **TUI sidebar**: Real-time sidebar in the OpenCode TUI showing rule status with active/inactive indicators
## Quick Start
### Installation
+```bash
+opencode plugin opencode-rules@latest --global
+```
+
+
+Manual installation
+
Add the plugin to your opencode config:
```json
@@ -47,6 +59,17 @@ Add the plugin to your opencode config:
}
```
+To enable the TUI sidebar, add the same plugin entry to your TUI config:
+
+```json
+// ~/.config/opencode/tui.json
+{
+ "plugin": ["opencode-rules@latest"]
+}
+```
+
+
+
### Create Your First Rule
1. Create the global rules directory:
@@ -69,13 +92,42 @@ Add the plugin to your opencode config:
That's it! The rule will now be automatically injected into all AI agent prompts.
+## How It Works
+
+1. **Discovery**: Scan global and project directories for `.md` and `.mdc` files (at plugin init)
+2. **Parsing**: Extract metadata from files with YAML front matter
+3. **Tool Execution**: `tool.execute.before` hook captures file paths before tools run
+4. **Message Flow**: `chat.message` hook updates user prompt as messages arrive
+ - Tracks turn count, model ID, and agent type for rule filtering
+5. **Initial Seeding**: `experimental.chat.messages.transform` extracts context from message history once
+6. **Rule Filtering**: `experimental.chat.system.transform` evaluates rules based on context and injects into system prompt
+ - Dedup: Rules already present in the current system prompt are skipped (content-based comparison)
+ - Hash-based dedup: Identical rule sets are not re-injected (KV-cache preservation)
+7. **State Persistence**: After filtering, matched rule paths are written to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption
+8. **Compaction Persistence**: `experimental.session.compacting` preserves context during session compression
+9. **User Prompt Injection**: `experimental.chat.messages.transform` injects user/both-mode rules before the last user message
+ - Injected at configurable turn intervals via `repeat_every`
+ - Raw content only (no headers or preamble)
+ - Marked as synthetic to prevent accidental extraction
+
+## Performance
+
+- Rule discovery performed once at plugin initialization
+- Rule content cached with mtime-based invalidation for fast re-reads
+- Incremental session state tracking (set of paths, not message rescanning)
+- Per-session state pruned after 100 concurrent sessions to prevent memory growth
+- Efficient glob matching with `minimatch`
+- Tool-based path capture is non-blocking with minimal overhead
+- Session context cleaned up when exceeded (LRU eviction)
+- Minimal memory footprint with efficient state management
+
## Configuration
### Rule Discovery Locations
Rules are automatically discovered from these directories (including all subdirectories):
-1. **Global rules**: `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`)
+1. **Global rules**: `$OPENCODE_CONFIG_DIR/rules/` if set, otherwise `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`)
2. **Project rules**: `.opencode/rules/` (in your project root)
Both directories are scanned recursively, allowing you to organize rules into subdirectories.
@@ -85,8 +137,128 @@ Both directories are scanned recursively, allowing you to organize rules into su
- `.md` - Standard markdown files with optional metadata
- `.mdc` - Markdown files with optional metadata
+## Metadata Format
+
+Both `.md` and `.mdc` files support optional YAML metadata for conditional rule application:
+
+```yaml
+---
+globs:
+ - 'src/**/*.ts'
+ - 'lib/**/*.js'
+keywords:
+ - 'refactoring'
+ - 'cleanup'
+tools:
+ - 'mcp_websearch'
+ - 'mcp_lsp'
+model:
+ - gpt-5.3-codex
+ - claude-sonnet-4
+agent:
+ - programmer
+command:
+ - /plan
+ - /review
+project:
+ - node
+ - monorepo
+branch:
+ - main
+ - feature/*
+os:
+ - linux
+ - darwin
+ci: false
+# Matching mode
+match: any
+# Injection mode
+inject: system
+# Repeat interval (turns) for user-prompt injection
+repeat_every: 1
+---
+```
+
+### Supported Fields
+
+- `globs` (optional): Array of glob patterns for file-based matching
+ - Rule applies when any file in context matches a pattern
+- `keywords` (optional): Array of keywords for prompt-based matching
+ - Rule applies when the user's prompt contains any keyword
+ - Case-insensitive, word-boundary matching (e.g., "test" matches "testing")
+ - Does NOT match mid-word (e.g., "test" does NOT match "contest")
+- `tools` (optional): Array of tool IDs for tool-availability matching
+ - Rule applies when any listed tool is available to the agent
+ - Uses exact string matching against tool IDs (e.g., `mcp_websearch`, `mcp_bash`)
+ - Enable debug logging (`OPENCODE_RULES_DEBUG=1`) to see available tool IDs
+- `model` (optional): Array of model IDs to match against the current LLM
+ - Example: `['gpt-5.3-codex', 'claude-sonnet-4']`
+- `agent` (optional): Array of agent types to match
+ - Example: `['programmer', 'planner']`
+- `command` (optional): Array of slash commands to match
+ - Example: `['/plan', '/review']`
+- `project` (optional): Array of project type tags to match
+ - Detected automatically from marker files (e.g., `package.json` -> `node`)
+ - Supported tags: `node`, `python`, `go`, `rust`, `monorepo`, `browser-extension`
+- `branch` (optional): Array of git branch patterns to match
+ - Supports exact names and glob patterns (e.g., `feature/*`, `release/**`)
+ - Uses minimatch for glob matching
+- `os` (optional): Array of operating systems to match
+ - Values: `linux`, `darwin`, `win32`
+- `ci` (optional): Boolean to match CI environment
+ - `true` matches when running in CI, `false` matches when not in CI
+- `match` (optional): Matching mode for multiple conditions
+ - `any` (default): Rule applies if ANY declared condition matches
+ - `all`: Rule applies only if ALL declared conditions match
+- `inject` (optional): Where to inject the rule content
+ - `system` (default): Injected into the system prompt once per session
+ - `user`: Injected into user messages at configurable intervals
+ - `both`: Injected into both system prompt and user messages
+- `repeat_every` (optional): Minimum number of user turns between user-prompt injections
+ - Accepts a number (e.g., `3` to inject every 3rd turn) or a model-pattern map
+ - Model-pattern map example: `{ "gpt-4*": 3, "default": 1 }` (uses minimatch for pattern matching)
+ - Default: `1` (every turn) when `inject` is `user` or `both`
+ - Only applies to rules with `inject: user` or `inject: both`
+
+> [!NOTE]
+> When a runtime context value is unavailable (e.g., not in a git repository), that dimension is treated as a non-match.
+
+### Matching Behavior
+
+- **No metadata**: Rule applies unconditionally (always included)
+- **Only globs**: Rule applies when any context file matches
+- **Only keywords**: Rule applies when the user's prompt contains any keyword
+- **Only tools**: Rule applies when any listed tool is available
+- **Multiple conditions with `match: any` (default)**: Rule applies when ANY condition matches (OR logic across all fields)
+- **Multiple conditions with `match: all`**: Rule applies only when ALL declared conditions match
+
+## Glob Pattern Reference
+
+The plugin uses `minimatch` for pattern matching:
+
+| Pattern | Matches |
+| ----------------------------- | ----------------------------------------------- |
+| `src/**/*.ts` | All TypeScript files in src and subdirectories |
+| `**/*.test.ts` | All test files at any depth |
+| `src/components/**/*.tsx` | React components in components directory |
+| `*.json` | JSON files in root directory only |
+| `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories |
+
+## Included Skill: crafting-rules
+
+This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides:
+
+- **Rule format reference** - Frontmatter fields (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure
+- **Matching strategy guidance** - When to use globs vs keywords vs runtime filters vs combinations
+- **Pattern extraction workflow** - How to identify repeated conversation patterns that should become rules
+- **Keyword safety guidelines** - Denylist of overly broad keywords to avoid, allowlist of safe alternatives, and an audit checklist
+
+To use the skill, copy `skills/crafting-rules/` to `~/.config/opencode/skills/` or reference it directly. The skill triggers when users ask to create rules, codify preferences, or persist guidance across sessions.
+
## Usage Examples
+For real-world examples, see the [`.opencode/rules/`](.opencode/rules/) directory in this repository.
+
### Basic Rule File
Create `~/.config/opencode/rules/naming-convention.md`:
@@ -282,117 +454,6 @@ globs:
- Co-locate styles with components
```
-## Metadata Format
-
-Both `.md` and `.mdc` files support optional YAML metadata for conditional rule application:
-
-```yaml
----
-# Legacy filters
-globs:
- - 'src/**/*.ts'
- - 'lib/**/*.js'
-keywords:
- - 'refactoring'
- - 'cleanup'
-tools:
- - 'mcp_websearch'
- - 'mcp_lsp'
-# Runtime environment filters
-model:
- - gpt-5.3-codex
- - claude-sonnet-4
-agent:
- - programmer
-command:
- - /plan
- - /review
-project:
- - node
- - monorepo
-branch:
- - main
- - feature/*
-os:
- - linux
- - darwin
-ci: false
-# Matching mode
-match: any
----
-```
-
-### Supported Fields
-
-#### Legacy Filters
-
-- `globs` (optional): Array of glob patterns for file-based matching
- - Rule applies when any file in context matches a pattern
-- `keywords` (optional): Array of keywords for prompt-based matching
- - Rule applies when the user's prompt contains any keyword
- - Case-insensitive, word-boundary matching (e.g., "test" matches "testing")
- - Does NOT match mid-word (e.g., "test" does NOT match "contest")
-- `tools` (optional): Array of tool IDs for tool-availability matching
- - Rule applies when any listed tool is available to the agent
- - Uses exact string matching against tool IDs (e.g., `mcp_websearch`, `mcp_bash`)
- - Enable debug logging (`OPENCODE_RULES_DEBUG=1`) to see available tool IDs
-
-#### Runtime Environment Filters
-
-- `model` (optional): Array of model IDs to match against the current LLM
- - Example: `['gpt-5.3-codex', 'claude-sonnet-4']`
-- `agent` (optional): Array of agent types to match
- - Example: `['programmer', 'planner']`
-- `command` (optional): Array of slash commands to match
- - Example: `['/plan', '/review']`
-- `project` (optional): Array of project type tags to match
- - Detected automatically from marker files (e.g., `package.json` -> `node`)
- - Supported tags: `node`, `python`, `go`, `rust`, `monorepo`, `browser-extension`
-- `branch` (optional): Array of git branch patterns to match
- - Supports exact names and glob patterns (e.g., `feature/*`, `release/**`)
- - Uses minimatch for glob matching
-- `os` (optional): Array of operating systems to match
- - Values: `linux`, `darwin`, `win32`
-- `ci` (optional): Boolean to match CI environment
- - `true` matches when running in CI, `false` matches when not in CI
-- `match` (optional): Matching mode for multiple conditions
- - `any` (default): Rule applies if ANY declared condition matches
- - `all`: Rule applies only if ALL declared conditions match
-
-**Note:** When a runtime context value is unavailable (e.g., not in a git repository), that dimension is treated as a non-match.
-
-### Matching Behavior
-
-- **No metadata**: Rule applies unconditionally (always included)
-- **Only globs**: Rule applies when any context file matches
-- **Only keywords**: Rule applies when the user's prompt contains any keyword
-- **Only tools**: Rule applies when any listed tool is available
-- **Multiple conditions with `match: any` (default)**: Rule applies when ANY condition matches (OR logic across all fields)
-- **Multiple conditions with `match: all`**: Rule applies only when ALL declared conditions match
-
-## Glob Pattern Reference
-
-The plugin uses `minimatch` for pattern matching:
-
-| Pattern | Matches |
-| ----------------------------- | ----------------------------------------------- |
-| `src/**/*.ts` | All TypeScript files in src and subdirectories |
-| `**/*.test.ts` | All test files at any depth |
-| `src/components/**/*.tsx` | React components in components directory |
-| `*.json` | JSON files in root directory only |
-| `lib/{utils,helpers}/**/*.js` | JavaScript files in specific lib subdirectories |
-
-## Included Skill: crafting-rules
-
-This repository includes a `crafting-rules/` skill that teaches AI agents how to create well-formatted rules. The skill provides:
-
-- **Rule format reference** - Frontmatter fields (`globs`, `keywords`, `tools`, `model`, `agent`, `command`, `project`, `branch`, `os`, `ci`, `match`) and markdown body structure
-- **Matching strategy guidance** - When to use globs vs keywords vs runtime filters vs combinations
-- **Pattern extraction workflow** - How to identify repeated conversation patterns that should become rules
-- **Keyword safety guidelines** - Denylist of overly broad keywords to avoid, allowlist of safe alternatives, and an audit checklist
-
-To use the skill, copy `skills/crafting-rules/` to `~/.config/opencode/skills/` or reference it directly. The skill triggers when users ask to create rules, codify preferences, or persist guidance across sessions.
-
## Development
### Project Structure (Abridged)
@@ -415,10 +476,20 @@ opencode-rules/
│ ├── project-fingerprint.ts # Project type detection (Node.js, Python, etc.)
│ ├── mcp-tools.ts # MCP tool ID extraction
│ ├── git-branch.ts # Git branch detection
+│ ├── active-rules-state.ts # Persists matched rules per session for TUI
│ ├── debug.ts # Debug logging utilities
│ ├── utils.ts # Re-export facade for backwards compatibility
│ ├── test-fixtures.ts # Shared test fixtures and builders
│ └── *.test.ts # Unit/integration tests (11 test files)
+├── tui/
+│ ├── index.tsx # TUI entrypoint, exports { id, tui }
+│ ├── slots/
+│ │ └── sidebar-content.tsx # Sidebar widget component
+│ ├── data/
+│ │ ├── rules.ts # Rule discovery + formatting for sidebar
+│ │ └── rules.test.ts # Data layer tests
+│ └── types/
+│ └── opencode-plugin-tui.d.ts # Vendored type shim
├── docs/
│ └── rules.md # Detailed usage documentation
├── openspec/ # Project specifications and proposals
@@ -434,12 +505,36 @@ The following highlights the primary runtime modules:
- **runtime-chat.ts** - Extracts text from chat message parts for keyword matching
- **rule-discovery.ts** - Recursively scans directories for `.md`/`.mdc` rule files
- **rule-metadata.ts** - Parses YAML frontmatter into typed `RuleMetadata`
-- **rule-filter.ts** - Evaluates rules against context (globs, keywords, tools, runtime filters)
-- **message-paths.ts** - Extracts file paths from message content
+- **rule-filter.ts** - Evaluates rules against context (globs, keywords, tools, runtime filters); returns `FilterResult` with `formattedRules` and `matchedPaths`
+- **message-paths.ts** - Extracts file paths from tool invocation arguments and message text
+- **message-context.ts** - Extracts user prompt text, slash commands, and session IDs from message parts
- **session-store.ts** - Manages per-session state with LRU eviction
- **project-fingerprint.ts** - Detects project type from marker files (e.g., `package.json`)
+- **mcp-tools.ts** - Maps connected MCP clients to tool IDs for `tools` condition matching
+- **git-branch.ts** - Resolves current git branch for `branch` condition matching
+- **active-rules-state.ts** - Persists which rules matched per session to `~/.opencode/state/opencode-rules/{sessionId}.json` for TUI consumption (atomic writes, per-session queuing)
- **utils.ts** - Thin facade re-exporting from decomposed modules
+### TUI Sidebar
+
+The plugin registers a `sidebar_content` slot in the OpenCode TUI, displaying all discovered rules (global and project-local) with their active state and metadata.
+
+**Requirements:** `@opencode-ai/plugin` ^1.3.7 with TUI support.
+
+**What it shows:**
+
+- Collapsible "Project" and "Global" sections grouping rules by scope
+- Active/inactive status indicators (green bullet for active, muted for inactive) based on persisted state from the current session
+- Condition summary for conditional rules ("always active" for unconditional ones)
+- Expandable detail panel with all metadata fields (globs, keywords, tools, model, agent, command, project, branch, os, ci, match)
+- Loading, error, and empty states
+
+**Behavior:**
+
+- Active rules are sorted to the top within each section
+- Subscribes to `message.updated` and `session.status` events for real-time refresh (150ms debounce, filtered by session ID)
+- Active state is read from `~/.opencode/state/opencode-rules/{sessionId}.json`, written by the server plugin after each rule evaluation
+
### Build and Test
```bash
@@ -512,14 +607,6 @@ This plugin uses OpenCode's hook system for incremental, stateful rule injection
- Injects current context paths into the compaction context
- Prevents rules from being lost during session compression
-### Benefits Over Previous Approach
-
-- **Incremental state tracking** - Builds context incrementally rather than rescanning messages each turn
-- **Authoritative path capture** - Tool hooks provide verified file paths directly from the tool definition
-- **Real-time responsiveness** - Context updates as tools execute and messages arrive
-- **Compaction-aware** - Context persists through session compression
-- **Efficient caching** - Rule discovery happens once at startup, not on every LLM call
-
### Experimental API Notice
This plugin depends on experimental OpenCode APIs:
@@ -530,27 +617,6 @@ This plugin depends on experimental OpenCode APIs:
These APIs may change in future OpenCode versions. Check OpenCode release notes when upgrading.
-## How It Works
-
-1. **Discovery**: Scan global and project directories for `.md` and `.mdc` files (at plugin init)
-2. **Parsing**: Extract metadata from files with YAML front matter
-3. **Tool Execution**: `tool.execute.before` hook captures file paths before tools run
-4. **Message Flow**: `chat.message` hook updates user prompt as messages arrive
-5. **Initial Seeding**: `experimental.chat.messages.transform` extracts context from message history once
-6. **Rule Filtering**: `experimental.chat.system.transform` evaluates rules based on context and injects into system prompt
-7. **Compaction Persistence**: `experimental.session.compacting` preserves context during session compression
-
-## Performance
-
-- Rule discovery performed once at plugin initialization
-- Rule content cached with mtime-based invalidation for fast re-reads
-- Incremental session state tracking (set of paths, not message rescanning)
-- Per-session state pruned after 100 concurrent sessions to prevent memory growth
-- Efficient glob matching with `minimatch`
-- Tool-based path capture is non-blocking with minimal overhead
-- Session context cleaned up when exceeded (LRU eviction)
-- Minimal memory footprint with efficient state management
-
## Debug Logging
To enable debug logging, set the `OPENCODE_RULES_DEBUG` environment variable:
@@ -573,7 +639,7 @@ This will log information about:
1. Verify directories exist: `~/.config/opencode/rules/` and/or `.opencode/rules/`
2. Check file extensions are `.md` or `.mdc`
3. Ensure files with metadata have properly formatted YAML
-4. Test glob patterns using the `fileMatchesGlobs()` function
+4. Enable debug logging (`OPENCODE_RULES_DEBUG=1`) to see which rules are being matched
### Common Issues
diff --git a/docs/rules.md b/docs/rules.md
index 3ecdd6f..952bbdc 100644
--- a/docs/rules.md
+++ b/docs/rules.md
@@ -10,7 +10,7 @@ This document explains how to use OpenCode Rules to inject custom instructions i
Rules are defined in Markdown files (`.md` or `.mdc`). These files can be located in two places:
-- **Global Rules:** `~/.config/opencode/rules/`
+- **Global Rules:** `$OPENCODE_CONFIG_DIR/rules/` if set, otherwise `$XDG_CONFIG_HOME/opencode/rules/` (typically `~/.config/opencode/rules/`)
- **Project Rules:** `.opencode/rules/` in the root of your project.
Both directories are scanned **recursively**, so you can organize your rules into subdirectories. Rule discovery happens once when the plugin initializes.
diff --git a/eslint.config.js b/eslint.config.js
index 2a77604..1a977e2 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -45,4 +45,59 @@ export default [
'@typescript-eslint/no-explicit-any': 'off',
},
},
+ // TUI production files
+ {
+ files: ['tui/**/*.ts', 'tui/**/*.tsx'],
+ ignores: [
+ 'tui/**/*.test.ts',
+ 'tui/**/*.test.tsx',
+ 'tui/**/*.spec.ts',
+ 'tui/**/*.spec.tsx',
+ ],
+ languageOptions: {
+ parser: typescriptParser,
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module',
+ project: './tsconfig.json',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': typescriptPlugin,
+ },
+ rules: {
+ ...typescriptPlugin.configs.recommended.rules,
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ { argsIgnorePattern: '^_' },
+ ],
+ },
+ },
+ // TUI test files
+ {
+ files: [
+ 'tui/**/*.test.ts',
+ 'tui/**/*.test.tsx',
+ 'tui/**/*.spec.ts',
+ 'tui/**/*.spec.tsx',
+ ],
+ languageOptions: {
+ parser: typescriptParser,
+ parserOptions: {
+ ecmaVersion: 2020,
+ sourceType: 'module',
+ },
+ },
+ plugins: {
+ '@typescript-eslint': typescriptPlugin,
+ },
+ rules: {
+ ...typescriptPlugin.configs.recommended.rules,
+ '@typescript-eslint/no-unused-vars': [
+ 'error',
+ { argsIgnorePattern: '^_' },
+ ],
+ '@typescript-eslint/no-explicit-any': 'off',
+ },
+ },
];
diff --git a/package.json b/package.json
index a686c86..e9046f4 100644
--- a/package.json
+++ b/package.json
@@ -1,20 +1,24 @@
{
"name": "opencode-rules",
- "version": "0.4.0",
+ "version": "0.6.4",
"description": "OpenCode plugin that discovers and injects markdown rules into system prompts",
- "main": "dist/index.js",
- "types": "dist/index.d.ts",
+ "main": "dist/src/index.js",
+ "types": "dist/src/index.d.ts",
"type": "module",
+ "oc-plugin": [
+ "server",
+ "tui"
+ ],
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "vitest",
"test:run": "vitest run",
- "lint": "eslint src",
- "format": "prettier --write src/**/*.ts",
- "format:check": "prettier --check src/**/*.ts",
+ "lint": "eslint src tui",
+ "format": "prettier --write 'src/**/*.ts' 'tui/**/*.tsx' 'tui/**/*.ts'",
+ "format:check": "prettier --check 'src/**/*.ts' 'tui/**/*.tsx' 'tui/**/*.ts'",
"clean": "rm -rf dist",
- "prepublishOnly": "npm run clean && npm run build"
+ "prepublishOnly": "bun run clean && bun run build"
},
"keywords": [
"opencode",
@@ -34,29 +38,57 @@
},
"files": [
"dist",
+ "!dist/**/*.test.*",
+ "!dist/**/*.spec.*",
+ "!dist/**/test-fixtures.*",
+ "!dist/**/api-surface.typecheck.*",
+ "src/",
+ "!src/**/*.test.ts",
+ "!src/**/*.spec.ts",
+ "!src/test-fixtures.ts",
+ "!src/api-surface.typecheck.ts",
+ "tui/",
+ "!tui/**/*.test.ts",
+ "!tui/**/*.test.tsx",
+ "!tui/**/*.spec.ts",
+ "!tui/**/*.spec.tsx",
"README.md",
- "lICENSE"
+ "LICENSE"
],
"exports": {
".": {
- "types": "./dist/index.d.ts",
- "import": "./dist/index.js"
+ "types": "./dist/src/index.d.ts",
+ "import": "./dist/src/index.js"
+ },
+ "./tui": {
+ "types": "./dist/tui/index.d.ts",
+ "import": "./tui/index.tsx"
}
},
+ "peerDependencies": {
+ "@opencode-ai/plugin": "^1.3.7",
+ "@opencode-ai/sdk": "^1.3.7"
+ },
"devDependencies": {
+ "@opencode-ai/plugin": "^1.3.9",
+ "@opencode-ai/sdk": "^1.3.9",
+ "@opentui/core": "^0.1.93",
+ "@opentui/solid": "^0.1.93",
"@types/minimatch": "^5.1.2",
"@types/node": "^20.19.30",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"eslint": "^8.57.1",
"prettier": "^3.8.1",
+ "solid-js": "1.9.11",
"typescript": "^5.9.3",
"vitest": "^1.6.1"
},
"dependencies": {
- "@opencode-ai/plugin": "^1.1.34",
- "@opencode-ai/sdk": "^1.1.34",
+ "@opentui/core": "^0.1.93",
+ "@opentui/solid": "^0.1.93",
"minimatch": "^9.0.5",
+ "solid-js": "1.9.11",
"yaml": "^2.8.2"
}
}
diff --git a/skills/crafting-rules/SKILL.md b/skills/crafting-rules/SKILL.md
index e9b8c8d..b85acea 100644
--- a/skills/crafting-rules/SKILL.md
+++ b/skills/crafting-rules/SKILL.md
@@ -1,11 +1,34 @@
---
name: crafting-rules
-description: Use when creating or modifying OpenCode rules (.md/.mdc files) that customize agent behavior. Helps extract patterns from conversation history, analyze project conventions (AGENTS.md, linters, package.json), and draft well-formatted rules with appropriate globs/keywords. Trigger when user wants to create a rule, codify repeated instructions, persist guidance across sessions, or customize agent behavior for specific files or topics.
+description: Use when creating or modifying OpenCode rules (.md/.mdc files) that customize agent behavior. Trigger when user wants to create a rule, codify repeated instructions, persist guidance across sessions, or scope rules to specific files, prompts, environments, or workflows.
---
# Crafting Rules
-Rules are markdown files injected into the system prompt to guide agent behavior.
+## Overview
+
+Rules are markdown files with optional YAML frontmatter, injected into the system prompt to guide agent behavior. Scope them with filters or leave unconditional for global standards.
+
+## Field Reference
+
+| Field | Type | Category | Purpose |
+| ---------- | ------------------ | ---------- | ---------------------------------------------------------- |
+| `globs` | `string[]` | Legacy | Apply when any file in context matches a pattern |
+| `keywords` | `string[]` | Legacy | Apply when the user's latest prompt matches a keyword |
+| `tools` | `string[]` | Legacy | Apply when any listed tool ID is available |
+| `model` | `string[]` | Runtime | Match against the current LLM model ID |
+| `agent` | `string[]` | Runtime | Match against the current agent type (e.g., `programmer`) |
+| `command` | `string[]` | Runtime | Match against the current slash command (e.g., `/plan`) |
+| `project` | `string[]` | Runtime | Match against detected project tags (e.g., `node`, `rust`) |
+| `branch` | `string[]` | Runtime | Match against git branch name (supports glob patterns) |
+| `os` | `string[]` | Runtime | Match against OS (`linux`, `darwin`, `win32`) |
+| `ci` | `boolean` | Runtime | Match against CI environment (`true` = in CI) |
+| `match` | `'any'` \| `'all'` | Combinator | `any` (default): OR logic. `all`: AND logic. |
+
+- All fields are optional; no frontmatter means the rule always applies.
+- With `match: any` (default), the rule applies if ANY declared condition matches.
+- With `match: all`, the rule applies only if ALL declared conditions match.
+- When a runtime value is unavailable (e.g., no git repo), that dimension is a non-match.
## Rule Format
@@ -15,6 +38,13 @@ globs:
- '**/*.ts'
keywords:
- 'vitest'
+model:
+ - claude-sonnet-4
+agent:
+ - programmer
+branch:
+ - feature/*
+match: any
---
# Rule Title
@@ -22,32 +52,37 @@ keywords:
- Write rules as concrete, actionable instructions.
```
-## Field Reference
-
-| Field | Type | Purpose |
-| ---------- | ---------- | ----------------------------------------------------- |
-| `globs` | `string[]` | Apply when any file in context matches a pattern |
-| `keywords` | `string[]` | Apply when the user's latest prompt matches a keyword |
-
-- Both fields are optional; no frontmatter means the rule always applies.
-- If both `globs` and `keywords` are present, matching is OR (either triggers).
-
## Matching Strategy
- Use `globs` when the rule is about code in specific files/directories.
- Use `keywords` when the rule is about a topic that may not include files.
-- Use both when either condition should trigger.
-- Use neither for global standards (tone, structure, safety, commit conventions).
+- Use `tools` when the rule depends on specific MCP tools being available.
+- Use runtime filters (`model`, `agent`, `command`, `project`, `branch`, `os`, `ci`) to scope rules to specific environments or workflows.
+- Use `match: all` when you need every declared condition to be true (AND logic).
+- Use `match: any` (or omit `match`) when any single condition should trigger (OR logic).
+- Use no filters for global standards (tone, structure, safety, commit conventions).
Important constraints:
- Keyword matching is case-insensitive word-boundary _prefix_ matching (e.g., `test` matches `tests` and `testing`).
-- You cannot express `globs AND keywords`; if you need that behavior, split into multiple rules.
+- Branch patterns support globs via minimatch (e.g., `feature/*`, `release/**`).
+- Missing runtime context (e.g., no git repo for `branch`) counts as a non-match for that dimension.
-## Storage Location
+## Keyword Selection
-- `~/.config/opencode/rules/`: personal preferences you want across projects.
-- `.opencode/rules/`: project/team conventions and repo-specific behavior.
+Keywords use case-insensitive word-boundary prefix matching — short or generic words over-match.
+
+Denylist: generic nouns (`code`, `file`, `project`, `repo`, `bug`, `issue`, `change`), common verbs (`add`, `update`, `remove`, `fix`, `make`, `create`, `implement`), over-broad topics (`testing`, `performance`, `security`, `deployment`, `database`, `api`), single-token abbreviations (`ci`, `cd`, `db`, `ui`, `ux`).
+
+Allowlist: tool/framework names (`vitest`, `jest`, `pytest`, `playwright`, `cypress`, `eslint`, `prettier`, `typescript`, `terraform`, `kubernetes`), compound phrases (`unit test`, `integration test`, `snapshot test`, `lint rule`, `error boundary`, `api endpoint`, `rest api`), high-intent verbs (`refactor`, `rollback`, `migrate`, `deprecate`).
+
+Audit checklist:
+
+- Would this keyword appear in prompts where the rule should NOT apply?
+- Is it likely to appear as part of another word due to prefix matching?
+- Can you scope via globs instead?
+- Prefer globs (file-scoped) over denylisted keywords.
+- Replace generic keywords with compound phrases or tool names that capture intent.
## Extracting Rules from Patterns
@@ -79,35 +114,10 @@ Conversation extraction examples:
- User: "In unit tests, always use describe/it blocks" -> prefer glob-scoped rule (e.g., `**/*.{test,spec}.*`, `**/__tests__/**`); if prompt-scoped, use allowlisted keywords like `unit test`, `vitest`, `jest` (avoid `test`/`testing`).
- User repeatedly fixes import ordering -> glob-scoped rule for the relevant languages/files.
-## Keyword Selection Guidelines
-
-How matching works (important):
-
-- Keywords are matched with case-insensitive word-boundary prefix matching, so short/generic keywords tend to over-match.
-
-Denylist (avoid by default):
-
-- Generic nouns: `code`, `file`, `project`, `repo`, `bug`, `issue`, `change`
-- Common verbs: `add`, `update`, `remove`, `fix`, `make`, `create`, `implement`
-- Over-broad topic nouns: `testing`, `performance`, `security`, `deployment`, `database`, `api`
-- Single-token abbreviations: `ci`, `cd`, `db`, `ui`, `ux`
-
-Allowlist (prefer by default):
-
-- Tool/framework names: `vitest`, `jest`, `pytest`, `playwright`, `cypress`, `eslint`, `prettier`, `typescript`, `terraform`, `kubernetes`
-- Compound phrases: `unit test`, `integration test`, `snapshot test`, `lint rule`, `error boundary`, `api endpoint`, `rest api`
-- High-intent engineering verbs: `refactor`, `rollback`, `migrate`, `deprecate`
-
-If you feel tempted to use a denylisted keyword:
-
-- Prefer globs (file-scoped) instead.
-- Or replace it with a compound phrase / tool name that captures intent.
-
-Keyword audit checklist:
+## Storage Location
-- Would this keyword appear in prompts where the rule should NOT apply?
-- Is it likely to appear as part of another word due to prefix matching?
-- Can you scope via globs instead?
+- `~/.config/opencode/rules/`: personal preferences you want across projects.
+- `.opencode/rules/`: project/team conventions and repo-specific behavior.
## Writing Guidelines
@@ -159,22 +169,31 @@ Unconditional: always-on standards
- Extract magic numbers to named constants.
```
-Combined: deployment safety (OR logic)
+Runtime filters with `match: all`: feature branch development
```md
---
-globs:
- - '**/deploy/**'
- - '**/*.tf'
-keywords:
- - 'terraform'
- - 'kubernetes'
- - 'production'
- - 'rollback'
+agent:
+ - programmer
+branch:
+ - feature/*
+os:
+ - linux
+ - darwin
+ci: false
+match: all
---
-# Deployment
+# Feature Branch Dev
-- Never hardcode secrets; use environment variables or a secrets manager.
-- Include rollback steps in any production change plan.
+- Create atomic commits with clear messages.
+- Run tests before pushing.
```
+
+## Common Mistakes
+
+- **Using denylisted keywords**: `test` fires on nearly every prompt — use `unit test` or globs instead.
+- **Forgetting `match: all`**: Two filters with default OR means EITHER triggers — add `match: all` for AND logic.
+- **Overloading a single rule**: 6+ dimensions are hard to reason about — split into focused rules.
+- **Duplicating lint/formatter config**: Check Prettier/ESLint before adding a style rule.
+- **Using `ci` as a keyword**: Prefix-matches `circuit`, `citizen` — use `ci: true` boolean filter instead.
diff --git a/src/active-rules-state.test.ts b/src/active-rules-state.test.ts
new file mode 100644
index 0000000..86de4fe
--- /dev/null
+++ b/src/active-rules-state.test.ts
@@ -0,0 +1,262 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import * as os from 'node:os';
+import {
+ resolveStateDir,
+ getStateFilePath,
+ writeActiveRulesState,
+ readActiveRulesState,
+ _setStateDirForTesting,
+} from './active-rules-state.js';
+
+describe('active-rules-state', () => {
+ let testStateDir: string;
+
+ beforeEach(async () => {
+ // Create a temp directory for tests
+ const testDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'active-rules-test-')
+ );
+ testStateDir = path.join(testDir, 'state');
+
+ // Use test override instead of mocking os.homedir
+ _setStateDirForTesting(testStateDir);
+ });
+
+ afterEach(async () => {
+ // Reset the override
+ _setStateDirForTesting(null);
+
+ // Clean up test directory
+ if (testStateDir) {
+ try {
+ // Go up one level to remove the whole temp dir
+ const parentDir = path.dirname(testStateDir);
+ await fs.rm(parentDir, { recursive: true });
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+ });
+
+ describe('resolveStateDir', () => {
+ it('returns overridden path when set', () => {
+ const stateDir = resolveStateDir();
+ expect(stateDir).toBe(testStateDir);
+ });
+
+ it('returns default path when not overridden', () => {
+ _setStateDirForTesting(null);
+ const stateDir = resolveStateDir();
+ expect(stateDir).toBe(
+ path.join(os.homedir(), '.opencode', 'state', 'opencode-rules')
+ );
+ });
+ });
+
+ describe('getStateFilePath', () => {
+ it('returns session-specific JSON path', () => {
+ const filePath = getStateFilePath('ses_123');
+ expect(filePath).toBe(path.join(testStateDir, 'ses_123.json'));
+ });
+
+ it('throws for sessionId with path traversal', () => {
+ expect(() => getStateFilePath('../escape')).toThrow('Invalid sessionId');
+ expect(() => getStateFilePath('foo/bar')).toThrow('Invalid sessionId');
+ expect(() => getStateFilePath('/absolute')).toThrow('Invalid sessionId');
+ });
+
+ it('throws for sessionId with special characters', () => {
+ expect(() => getStateFilePath('ses.123')).toThrow('Invalid sessionId');
+ expect(() => getStateFilePath('ses 123')).toThrow('Invalid sessionId');
+ expect(() => getStateFilePath('')).toThrow('Invalid sessionId');
+ });
+ });
+
+ describe('writeActiveRulesState and readActiveRulesState', () => {
+ it('write/read round-trip preserves data', async () => {
+ const sessionId = 'ses_roundtrip';
+ const matchedPaths = ['/path/to/rule1.md', '/path/to/rule2.md'];
+
+ writeActiveRulesState(sessionId, matchedPaths);
+
+ // Wait for the fire-and-forget write to complete
+ await waitForFile(getStateFilePath(sessionId));
+
+ const state = await readActiveRulesState(sessionId);
+
+ expect(state).not.toBeNull();
+ expect(state!.sessionId).toBe(sessionId);
+ expect(state!.matchedRulePaths).toEqual(matchedPaths);
+ expect(typeof state!.evaluatedAt).toBe('number');
+ expect(state!.evaluatedAt).toBeLessThanOrEqual(Date.now());
+ });
+
+ it('returns null for missing file', async () => {
+ const state = await readActiveRulesState('ses_nonexistent');
+ expect(state).toBeNull();
+ });
+
+ it('returns null for corrupt/invalid JSON', async () => {
+ await fs.mkdir(testStateDir, { recursive: true });
+
+ const filePath = getStateFilePath('ses_corrupt');
+ await fs.writeFile(filePath, 'not valid json {{{', 'utf-8');
+
+ const state = await readActiveRulesState('ses_corrupt');
+ expect(state).toBeNull();
+ });
+
+ it('returns null for invalid state format', async () => {
+ await fs.mkdir(testStateDir, { recursive: true });
+
+ const filePath = getStateFilePath('ses_invalid');
+ await fs.writeFile(filePath, JSON.stringify({ foo: 'bar' }), 'utf-8');
+
+ const state = await readActiveRulesState('ses_invalid');
+ expect(state).toBeNull();
+ });
+
+ it('returns null for wrong-type values in state', async () => {
+ await fs.mkdir(testStateDir, { recursive: true });
+
+ const filePath = getStateFilePath('ses_wrongtypes');
+ await fs.writeFile(
+ filePath,
+ JSON.stringify({
+ sessionId: 123,
+ matchedRulePaths: 'not-an-array',
+ evaluatedAt: 'not-a-number',
+ }),
+ 'utf-8'
+ );
+
+ const state = await readActiveRulesState('ses_wrongtypes');
+ expect(state).toBeNull();
+ });
+
+ it('returns null for array with non-string items', async () => {
+ await fs.mkdir(testStateDir, { recursive: true });
+
+ const filePath = getStateFilePath('ses_badarray');
+ await fs.writeFile(
+ filePath,
+ JSON.stringify({
+ sessionId: 'ses_badarray',
+ matchedRulePaths: ['/valid.md', 123, null],
+ evaluatedAt: Date.now(),
+ }),
+ 'utf-8'
+ );
+
+ const state = await readActiveRulesState('ses_badarray');
+ expect(state).toBeNull();
+ });
+
+ it('silently ignores write with invalid sessionId', async () => {
+ writeActiveRulesState('../escape', ['/rule.md']);
+ writeActiveRulesState('foo/bar', ['/rule.md']);
+
+ // Give time for any writes to occur
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ // Verify no files were created
+ try {
+ await fs.access(testStateDir);
+ const files = await fs.readdir(testStateDir);
+ expect(files).toHaveLength(0);
+ } catch {
+ // Directory doesn't exist, which is expected
+ }
+ });
+
+ it('returns null for read with invalid sessionId', async () => {
+ const state = await readActiveRulesState('../escape');
+ expect(state).toBeNull();
+ });
+
+ it('no temp file remains after write', async () => {
+ const sessionId = 'ses_no_temp';
+ const matchedPaths = ['/rule.md'];
+
+ writeActiveRulesState(sessionId, matchedPaths);
+
+ // Wait for write to complete
+ await waitForFile(getStateFilePath(sessionId));
+
+ // Check that no temp files remain
+ const files = await fs.readdir(testStateDir);
+ const tempFiles = files.filter(f => f.endsWith('.tmp'));
+
+ expect(tempFiles).toHaveLength(0);
+ });
+
+ it('serializes concurrent writes for same session', async () => {
+ const sessionId = 'ses_concurrent';
+
+ // Fire multiple writes concurrently
+ writeActiveRulesState(sessionId, ['path1']);
+ writeActiveRulesState(sessionId, ['path2']);
+ writeActiveRulesState(sessionId, ['path3']);
+
+ // Wait for all writes to complete
+ await waitForFile(getStateFilePath(sessionId));
+
+ // Give a bit more time for all queued writes to finish
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // The final state should reflect the last write
+ const state = await readActiveRulesState(sessionId);
+ expect(state).not.toBeNull();
+ expect(state!.matchedRulePaths).toEqual(['path3']);
+ });
+
+ it('creates state directory when it does not exist', async () => {
+ const sessionId = 'ses_newdir';
+ const matchedPaths = ['/rule.md'];
+
+ // Verify directory doesn't exist yet
+ await expect(fs.access(testStateDir)).rejects.toThrow();
+
+ writeActiveRulesState(sessionId, matchedPaths);
+
+ // Wait for write to complete
+ await waitForFile(getStateFilePath(sessionId));
+
+ // Verify directory now exists
+ await expect(fs.access(testStateDir)).resolves.toBeUndefined();
+ });
+
+ it('handles writes to different sessions independently', async () => {
+ writeActiveRulesState('ses_a', ['ruleA']);
+ writeActiveRulesState('ses_b', ['ruleB']);
+
+ // Wait for both writes
+ await Promise.all([
+ waitForFile(getStateFilePath('ses_a')),
+ waitForFile(getStateFilePath('ses_b')),
+ ]);
+
+ const stateA = await readActiveRulesState('ses_a');
+ const stateB = await readActiveRulesState('ses_b');
+
+ expect(stateA!.matchedRulePaths).toEqual(['ruleA']);
+ expect(stateB!.matchedRulePaths).toEqual(['ruleB']);
+ });
+ });
+});
+
+// Helper to wait for a file to exist
+async function waitForFile(filePath: string, timeoutMs = 1000): Promise {
+ const start = Date.now();
+ while (Date.now() - start < timeoutMs) {
+ try {
+ await fs.access(filePath);
+ return;
+ } catch {
+ await new Promise(resolve => setTimeout(resolve, 10));
+ }
+ }
+ throw new Error(`Timed out waiting for file: ${filePath}`);
+}
diff --git a/src/active-rules-state.ts b/src/active-rules-state.ts
new file mode 100644
index 0000000..aaba3ec
--- /dev/null
+++ b/src/active-rules-state.ts
@@ -0,0 +1,166 @@
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import * as crypto from 'node:crypto';
+import { createDebugLog } from './debug';
+
+const debugLog = createDebugLog();
+
+export interface ActiveRulesState {
+ sessionId: string;
+ matchedRulePaths: string[];
+ evaluatedAt: number;
+}
+
+// Per-session write queue to serialize concurrent writes
+const writeQueues = new Map>();
+
+// Allows tests to override the state directory
+let stateDirOverride: string | null = null;
+
+// Strict pattern for safe sessionId: alphanumeric, underscore, hyphen only
+const SAFE_SESSION_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
+
+function isValidSessionId(sessionId: string): boolean {
+ return SAFE_SESSION_ID_PATTERN.test(sessionId);
+}
+
+/** @internal Test-only: override the state directory */
+export function _setStateDirForTesting(dir: string | null): void {
+ stateDirOverride = dir;
+}
+
+export function resolveStateDir(): string {
+ if (stateDirOverride !== null) {
+ return stateDirOverride;
+ }
+ return path.join(os.homedir(), '.opencode', 'state', 'opencode-rules');
+}
+
+export function getStateFilePath(sessionId: string): string {
+ if (!isValidSessionId(sessionId)) {
+ throw new Error(`Invalid sessionId: ${sessionId}`);
+ }
+ return path.join(resolveStateDir(), `${sessionId}.json`);
+}
+
+export function writeActiveRulesState(
+ sessionId: string,
+ matchedPaths: string[]
+): void {
+ if (!isValidSessionId(sessionId)) {
+ debugLog(`Invalid sessionId rejected: ${sessionId}`);
+ return;
+ }
+
+ const state: ActiveRulesState = {
+ sessionId,
+ matchedRulePaths: matchedPaths,
+ evaluatedAt: Date.now(),
+ };
+
+ // Chain onto existing queue for this session, or start fresh
+ const previousWrite = writeQueues.get(sessionId) ?? Promise.resolve();
+
+ const currentWrite = previousWrite.then(async () => {
+ await doAtomicWrite(sessionId, state);
+ });
+
+ writeQueues.set(sessionId, currentWrite);
+
+ // Fire-and-forget: catch errors to prevent unhandled rejection
+ currentWrite.catch(() => {
+ // Errors already logged in doAtomicWrite
+ });
+}
+
+async function doAtomicWrite(
+ sessionId: string,
+ state: ActiveRulesState
+): Promise {
+ const stateDir = resolveStateDir();
+ const finalPath = getStateFilePath(sessionId);
+ const tempPath = path.join(
+ stateDir,
+ `.${sessionId}-${crypto.randomBytes(8).toString('hex')}.tmp`
+ );
+
+ try {
+ // Ensure directory exists
+ await fs.mkdir(stateDir, { recursive: true });
+
+ // Write to temp file
+ const content = JSON.stringify(state);
+ await fs.writeFile(tempPath, content, 'utf-8');
+
+ // Atomic rename
+ await fs.rename(tempPath, finalPath);
+ } catch (error) {
+ debugLog(
+ `Failed to write active rules state for session ${sessionId}: ${error}`
+ );
+
+ // Clean up temp file if it exists
+ try {
+ await fs.unlink(tempPath);
+ } catch {
+ // Ignore cleanup errors
+ }
+ }
+}
+
+export async function readActiveRulesState(
+ sessionId: string
+): Promise {
+ if (!isValidSessionId(sessionId)) {
+ debugLog(`Invalid sessionId rejected: ${sessionId}`);
+ return null;
+ }
+
+ const filePath = getStateFilePath(sessionId);
+
+ try {
+ const content = await fs.readFile(filePath, 'utf-8');
+ const parsed: unknown = JSON.parse(content);
+
+ if (!isValidActiveRulesState(parsed)) {
+ debugLog(`Invalid active rules state format for session ${sessionId}`);
+ return null;
+ }
+
+ return parsed;
+ } catch (error) {
+ debugLog(
+ `Failed to read active rules state for session ${sessionId}: ${error}`
+ );
+ return null;
+ }
+}
+
+function isValidActiveRulesState(value: unknown): value is ActiveRulesState {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+
+ const obj = value as Record;
+
+ if (typeof obj['sessionId'] !== 'string') {
+ return false;
+ }
+
+ if (typeof obj['evaluatedAt'] !== 'number') {
+ return false;
+ }
+
+ if (!Array.isArray(obj['matchedRulePaths'])) {
+ return false;
+ }
+
+ for (const item of obj['matchedRulePaths']) {
+ if (typeof item !== 'string') {
+ return false;
+ }
+ }
+
+ return true;
+}
diff --git a/src/debug.test.ts b/src/debug.test.ts
index b18b418..cde6e22 100644
--- a/src/debug.test.ts
+++ b/src/debug.test.ts
@@ -1,9 +1,16 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import {
+ describe,
+ it,
+ expect,
+ vi,
+ beforeEach,
+ afterEach,
+ type MockInstance,
+} from 'vitest';
import { createDebugLog } from './debug.js';
describe('createDebugLog', () => {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- let debugSpy: any;
+ let debugSpy: MockInstance;
beforeEach(() => {
debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
diff --git a/src/index.integration.test.ts b/src/index.integration.test.ts
index 009f275..27acef4 100644
--- a/src/index.integration.test.ts
+++ b/src/index.integration.test.ts
@@ -19,10 +19,13 @@ import { __testOnly } from './index.js';
describe('readAndFormatRules', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
clearRuleCache();
});
@@ -33,6 +36,11 @@ describe('readAndFormatRules', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('should read and format rule files into a formatted string', async () => {
@@ -42,18 +50,20 @@ describe('readAndFormatRules', () => {
writeFileSync(rule1Path, '# Rule 1\nContent of rule 1');
writeFileSync(rule2Path, '# Rule 2\nContent of rule 2');
- const formatted = await readAndFormatRules(toRules([rule1Path, rule2Path]));
+ const { formattedRules } = await readAndFormatRules(
+ toRules([rule1Path, rule2Path])
+ );
- expect(formatted).toContain('OpenCode Rules');
- expect(formatted).toContain('rule1.md');
- expect(formatted).toContain('rule2.md');
- expect(formatted).toContain('Rule 1');
- expect(formatted).toContain('Rule 2');
+ expect(formattedRules).toContain('OpenCode Rules');
+ expect(formattedRules).toContain('rule1.md');
+ expect(formattedRules).toContain('rule2.md');
+ expect(formattedRules).toContain('Rule 1');
+ expect(formattedRules).toContain('Rule 2');
});
it('should return empty string when no files provided', async () => {
- const formatted = await readAndFormatRules([]);
- expect(formatted).toBe('');
+ const { formattedRules } = await readAndFormatRules([]);
+ expect(formattedRules).toBe('');
});
it('should handle file read errors gracefully', async () => {
@@ -62,10 +72,10 @@ describe('readAndFormatRules', () => {
const validFile = path.join(globalRulesDir, 'valid.md');
writeFileSync(validFile, '# Valid Rule');
- const formatted = await readAndFormatRules(
+ const { formattedRules } = await readAndFormatRules(
toRules([nonExistentFile, validFile])
);
- expect(formatted).toContain('valid.md');
+ expect(formattedRules).toContain('valid.md');
});
it('should include filename as subheader in output', async () => {
@@ -73,8 +83,8 @@ describe('readAndFormatRules', () => {
const rulePath = path.join(globalRulesDir, 'my-rules.md');
writeFileSync(rulePath, 'Rule content');
- const formatted = await readAndFormatRules(toRules([rulePath]));
- expect(formatted).toMatch(/##\s+my-rules\.md/);
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]));
+ expect(formattedRules).toMatch(/##\s+my-rules\.md/);
});
it('should include rule when file matches glob pattern in metadata', async () => {
@@ -88,12 +98,14 @@ globs:
This is a rule for TypeScript components.`;
writeFileSync(rulePath, ruleContent);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/components/button.ts'],
});
- expect(formatted).toContain('typescript.mdc');
- expect(formatted).toContain('This is a rule for TypeScript components.');
+ expect(formattedRules).toContain('typescript.mdc');
+ expect(formattedRules).toContain(
+ 'This is a rule for TypeScript components.'
+ );
});
it('should exclude rule when file does not match glob pattern in metadata', async () => {
@@ -107,11 +119,11 @@ globs:
This is a rule for TypeScript components.`;
writeFileSync(rulePath, ruleContent);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/utils/helpers.js'],
});
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
it('should include rule when user prompt matches keywords', async () => {
@@ -128,12 +140,12 @@ keywords:
Follow testing best practices.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
userPrompt: 'I need help testing this function',
});
- expect(formatted).toContain('testing-rule.mdc');
- expect(formatted).toContain('Follow testing best practices');
+ expect(formattedRules).toContain('testing-rule.mdc');
+ expect(formattedRules).toContain('Follow testing best practices');
});
it('should include rule when tool is available', async () => {
@@ -149,12 +161,12 @@ tools:
Use web search best practices.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
availableToolIDs: ['mcp_bash', 'mcp_websearch', 'mcp_read'],
});
- expect(formatted).toContain('websearch-rule.mdc');
- expect(formatted).toContain('Use web search best practices');
+ expect(formattedRules).toContain('websearch-rule.mdc');
+ expect(formattedRules).toContain('Use web search best practices');
});
describe('new filter dimensions', () => {
@@ -172,12 +184,12 @@ model:
Model-specific rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
modelID: 'claude-opus',
});
- expect(formatted).toContain('model-rule.mdc');
- expect(formatted).toContain('Model-specific rule');
+ expect(formattedRules).toContain('model-rule.mdc');
+ expect(formattedRules).toContain('Model-specific rule');
});
it('should include rule when agent matches', async () => {
@@ -194,12 +206,12 @@ agent:
Agent-specific rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
agentType: 'programmer',
});
- expect(formatted).toContain('agent-rule.mdc');
- expect(formatted).toContain('Agent-specific rule');
+ expect(formattedRules).toContain('agent-rule.mdc');
+ expect(formattedRules).toContain('Agent-specific rule');
});
it('should include rule when os matches', async () => {
@@ -216,12 +228,12 @@ os:
Unix-specific rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
os: 'linux',
});
- expect(formatted).toContain('os-rule.mdc');
- expect(formatted).toContain('Unix-specific rule');
+ expect(formattedRules).toContain('os-rule.mdc');
+ expect(formattedRules).toContain('Unix-specific rule');
});
it('should include rule when ci is true and rule requires ci', async () => {
@@ -236,12 +248,12 @@ ci: true
CI-specific rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
ci: true,
});
- expect(formatted).toContain('ci-rule.mdc');
- expect(formatted).toContain('CI-specific rule');
+ expect(formattedRules).toContain('ci-rule.mdc');
+ expect(formattedRules).toContain('CI-specific rule');
});
it('should include rule when branch matches glob pattern', async () => {
@@ -258,12 +270,12 @@ branch:
Feature branch rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
gitBranch: 'feature/add-login',
});
- expect(formatted).toContain('branch-glob-rule.mdc');
- expect(formatted).toContain('Feature branch rule');
+ expect(formattedRules).toContain('branch-glob-rule.mdc');
+ expect(formattedRules).toContain('Feature branch rule');
});
});
@@ -285,14 +297,14 @@ os:
Default any match rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
modelID: 'claude-opus',
agentType: 'reviewer',
os: 'linux',
});
- expect(formatted).toContain('any-default.mdc');
- expect(formatted).toContain('Default any match rule');
+ expect(formattedRules).toContain('any-default.mdc');
+ expect(formattedRules).toContain('Default any match rule');
});
it('should require all declared dimensions when match is all', async () => {
@@ -313,14 +325,14 @@ match: all
All dimensions must match.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
modelID: 'claude-opus',
agentType: 'programmer',
os: 'linux',
});
- expect(formatted).toContain('all-match.mdc');
- expect(formatted).toContain('All dimensions must match');
+ expect(formattedRules).toContain('all-match.mdc');
+ expect(formattedRules).toContain('All dimensions must match');
});
it('should exclude rule when match: all and one dimension fails', async () => {
@@ -341,13 +353,13 @@ match: all
All dimensions must match.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
modelID: 'claude-opus',
agentType: 'programmer',
os: 'darwin',
});
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
});
@@ -362,9 +374,9 @@ All dimensions must match.`
const result1 = await readAndFormatRules(rules);
const result2 = await readAndFormatRules(rules);
- expect(result1).toContain('Cached Rule');
- expect(result2).toContain('Cached Rule');
- expect(result1).toBe(result2);
+ expect(result1.formattedRules).toContain('Cached Rule');
+ expect(result2.formattedRules).toContain('Cached Rule');
+ expect(result1.formattedRules).toBe(result2.formattedRules);
});
it('should invalidate cache when file is modified', async () => {
@@ -375,7 +387,7 @@ All dimensions must match.`
const rules = toRules([rulePath]);
const result1 = await readAndFormatRules(rules);
- expect(result1).toContain('Original Content');
+ expect(result1.formattedRules).toContain('Original Content');
// Write new content and explicitly set mtime to future to ensure cache invalidation
// This avoids flaky timing issues on CI/different filesystems
@@ -385,8 +397,8 @@ All dimensions must match.`
const result2 = await readAndFormatRules(rules);
- expect(result2).toContain('Modified Content');
- expect(result2).not.toContain('Original Content');
+ expect(result2.formattedRules).toContain('Modified Content');
+ expect(result2.formattedRules).not.toContain('Original Content');
});
it('should handle clearRuleCache correctly', async () => {
@@ -400,17 +412,20 @@ All dimensions must match.`
clearRuleCache();
const result = await readAndFormatRules(rules);
- expect(result).toContain('Test Content');
+ expect(result.formattedRules).toContain('Test Content');
});
});
});
describe('Cross-Dimension Regression Coverage', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
clearRuleCache();
});
@@ -421,6 +436,11 @@ describe('Cross-Dimension Regression Coverage', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
describe('omitted match behaves as any', () => {
@@ -473,8 +493,10 @@ Rule with explicit match any.`
context
);
- expect(omittedResult).toContain('Rule with omitted match');
- expect(explicitResult).toContain('Rule with explicit match any');
+ expect(omittedResult.formattedRules).toContain('Rule with omitted match');
+ expect(explicitResult.formattedRules).toContain(
+ 'Rule with explicit match any'
+ );
});
it('should exclude rule with omitted match when no dimension matches', async () => {
@@ -493,12 +515,12 @@ agent:
Rule that should not match.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
modelID: 'claude-opus',
agentType: 'reviewer',
});
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
});
@@ -525,7 +547,7 @@ agent:
Mixed legacy and new filters rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/index.ts'],
userPrompt: 'help with debugging',
availableToolIDs: ['mcp_bash'],
@@ -533,7 +555,7 @@ Mixed legacy and new filters rule.`
agentType: 'reviewer',
});
- expect(formatted).toContain('Mixed legacy and new filters rule');
+ expect(formattedRules).toContain('Mixed legacy and new filters rule');
});
it('should include rule when only new model filter matches (all legacy mismatch)', async () => {
@@ -558,7 +580,7 @@ agent:
New model filter matches rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/index.ts'],
userPrompt: 'help with typescript',
availableToolIDs: ['mcp_bash'],
@@ -566,7 +588,7 @@ New model filter matches rule.`
agentType: 'programmer',
});
- expect(formatted).toContain('New model filter matches rule');
+ expect(formattedRules).toContain('New model filter matches rule');
});
});
@@ -596,7 +618,7 @@ match: all
All dimensions match rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/utils.ts'],
userPrompt: 'help me refactor this code',
availableToolIDs: ['mcp_bash', 'mcp_read'],
@@ -605,7 +627,7 @@ All dimensions match rule.`
os: 'linux',
});
- expect(formatted).toContain('All dimensions match rule');
+ expect(formattedRules).toContain('All dimensions match rule');
});
it('should exclude rule when one legacy dimension fails (keywords mismatch)', async () => {
@@ -629,14 +651,14 @@ match: all
Keywords fail rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
contextFilePaths: ['src/utils.ts'],
userPrompt: 'help me refactor this code',
availableToolIDs: ['mcp_bash'],
modelID: 'claude-opus',
});
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
});
@@ -660,13 +682,15 @@ model:
Conditional rule for gpt-5 only.`
);
- const formatted = await readAndFormatRules(
+ const { formattedRules } = await readAndFormatRules(
toRules([unconditionalPath, conditionalPath]),
{ modelID: 'claude-opus' }
);
- expect(formatted).toContain('This rule always applies unconditionally');
- expect(formatted).not.toContain('Conditional rule for gpt-5 only');
+ expect(formattedRules).toContain(
+ 'This rule always applies unconditionally'
+ );
+ expect(formattedRules).not.toContain('Conditional rule for gpt-5 only');
});
it('should include unconditional rules even when filter context is empty', async () => {
@@ -690,13 +714,13 @@ keywords:
Only for special files.`
);
- const formatted = await readAndFormatRules(
+ const { formattedRules } = await readAndFormatRules(
toRules([unconditionalPath, conditionalPath]),
{}
);
- expect(formatted).toContain('No metadata means always apply');
- expect(formatted).not.toContain('Only for special files');
+ expect(formattedRules).toContain('No metadata means always apply');
+ expect(formattedRules).not.toContain('Only for special files');
});
it('should include unconditional rules when called with no context at all', async () => {
@@ -708,19 +732,122 @@ Only for special files.`
'# Bare Rule\nShould always be included.'
);
- const formatted = await readAndFormatRules(toRules([unconditionalPath]));
+ const { formattedRules } = await readAndFormatRules(
+ toRules([unconditionalPath])
+ );
- expect(formatted).toContain('Should always be included');
+ expect(formattedRules).toContain('Should always be included');
+ });
+ });
+
+ describe('matchedPaths tracking', () => {
+ it('should return matchedPaths with file paths of included rules', async () => {
+ const { globalRulesDir } = getTestDirs();
+ const rule1Path = path.join(globalRulesDir, 'rule1.md');
+ const rule2Path = path.join(globalRulesDir, 'rule2.md');
+ writeFileSync(rule1Path, '# Rule 1\nContent');
+ writeFileSync(rule2Path, '# Rule 2\nContent');
+
+ const { formattedRules, matchedPaths } = await readAndFormatRules(
+ toRules([rule1Path, rule2Path])
+ );
+
+ expect(formattedRules).toContain('Rule 1');
+ expect(matchedPaths).toHaveLength(2);
+ expect(matchedPaths).toContain(rule1Path);
+ expect(matchedPaths).toContain(rule2Path);
+ });
+
+ it('should return empty matchedPaths when no rules match', async () => {
+ const { globalRulesDir } = getTestDirs();
+ const rulePath = path.join(globalRulesDir, 'conditional.mdc');
+ writeFileSync(
+ rulePath,
+ `---
+model:
+ - gpt-5
+---
+
+Conditional rule.`
+ );
+
+ const { formattedRules, matchedPaths } = await readAndFormatRules(
+ toRules([rulePath]),
+ { modelID: 'claude-opus' }
+ );
+
+ expect(formattedRules).toBe('');
+ expect(matchedPaths).toHaveLength(0);
+ });
+
+ it('should return empty matchedPaths when files array is empty', async () => {
+ const { formattedRules, matchedPaths } = await readAndFormatRules([]);
+
+ expect(formattedRules).toBe('');
+ expect(matchedPaths).toHaveLength(0);
+ });
+
+ it('should only include matching rules in matchedPaths (not filtered-out rules)', async () => {
+ const { globalRulesDir } = getTestDirs();
+ const includedPath = path.join(globalRulesDir, 'included.mdc');
+ const excludedPath = path.join(globalRulesDir, 'excluded.mdc');
+
+ writeFileSync(
+ includedPath,
+ `---
+model:
+ - claude-opus
+---
+
+Included rule.`
+ );
+ writeFileSync(
+ excludedPath,
+ `---
+model:
+ - gpt-5
+---
+
+Excluded rule.`
+ );
+
+ const { formattedRules, matchedPaths } = await readAndFormatRules(
+ toRules([includedPath, excludedPath]),
+ { modelID: 'claude-opus' }
+ );
+
+ expect(formattedRules).toContain('Included rule');
+ expect(formattedRules).not.toContain('Excluded rule');
+ expect(matchedPaths).toHaveLength(1);
+ expect(matchedPaths).toContain(includedPath);
+ expect(matchedPaths).not.toContain(excludedPath);
+ });
+
+ it('should include unconditional rules in matchedPaths', async () => {
+ const { globalRulesDir } = getTestDirs();
+ const unconditionalPath = path.join(globalRulesDir, 'always.md');
+
+ writeFileSync(unconditionalPath, '# Always\nUnconditional rule.');
+
+ const { matchedPaths } = await readAndFormatRules(
+ toRules([unconditionalPath])
+ );
+
+ expect(matchedPaths).toHaveLength(1);
+ expect(matchedPaths).toContain(unconditionalPath);
});
});
});
describe('Conditional rules integration', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
clearRuleCache();
});
@@ -733,6 +860,11 @@ describe('Conditional rules integration', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('should include conditional rule when message context matches glob', async () => {
@@ -748,7 +880,9 @@ Use React best practices for components.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -807,7 +941,9 @@ Use React best practices for components.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -870,7 +1006,9 @@ Special rule content.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -918,10 +1056,13 @@ Special rule content.`
describe('Session compacting behavior', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
clearRuleCache();
});
@@ -934,13 +1075,21 @@ describe('Session compacting behavior', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('adds minimal working-set context during compaction', async () => {
const { testDir } = getTestDirs();
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -970,7 +1119,10 @@ describe('Session compacting behavior', () => {
const { testDir } = getTestDirs();
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -1007,7 +1159,10 @@ describe('Session compacting behavior', () => {
const { testDir } = getTestDirs();
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -1039,7 +1194,10 @@ describe('Session compacting behavior', () => {
const { testDir } = getTestDirs();
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -1080,7 +1238,9 @@ MCP Context7 rule content`;
writeFileSync(path.join(globalRulesDir, 'context7.md'), ruleContent);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({
testDir,
mcpStatus: { context7: { status: 'connected' } },
diff --git a/src/index.rules.test.ts b/src/index.rules.test.ts
index 2c85ace..324ef1f 100644
--- a/src/index.rules.test.ts
+++ b/src/index.rules.test.ts
@@ -988,7 +988,7 @@ describe('discoverRuleFiles', () => {
beforeEach(() => {
setupTestDirs();
- envSnapshot = saveEnv('XDG_CONFIG_HOME', 'HOME');
+ envSnapshot = saveEnv('XDG_CONFIG_HOME', 'HOME', 'OPENCODE_CONFIG_DIR');
});
afterEach(() => {
@@ -1068,6 +1068,49 @@ describe('discoverRuleFiles', () => {
const files = await discoverRuleFiles();
expect(files.every(f => !f.filePath.includes('.hidden.md'))).toBe(true);
});
+
+ it('should use OPENCODE_CONFIG_DIR/rules as global dir when set', async () => {
+ const { testDir } = getTestDirs();
+ const customDir = path.join(testDir, 'custom-config');
+ const customRulesDir = path.join(customDir, 'rules');
+ mkdirSync(customRulesDir, { recursive: true });
+ writeFileSync(path.join(customRulesDir, 'custom.md'), '# Custom Rule');
+
+ process.env.OPENCODE_CONFIG_DIR = customDir;
+
+ const files = await discoverRuleFiles();
+ expect(
+ files.some(f => f.filePath === path.join(customRulesDir, 'custom.md'))
+ ).toBe(true);
+ });
+
+ it('should prefer OPENCODE_CONFIG_DIR over XDG_CONFIG_HOME', async () => {
+ const { testDir, globalRulesDir } = getTestDirs();
+ writeFileSync(path.join(globalRulesDir, 'xdg-rule.md'), '# XDG Rule');
+
+ const customDir = path.join(testDir, 'custom-config');
+ const customRulesDir = path.join(customDir, 'rules');
+ mkdirSync(customRulesDir, { recursive: true });
+ writeFileSync(path.join(customRulesDir, 'custom.md'), '# Custom Rule');
+
+ process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
+ process.env.OPENCODE_CONFIG_DIR = customDir;
+
+ const files = await discoverRuleFiles();
+ expect(files.some(f => f.filePath.includes('custom.md'))).toBe(true);
+ expect(files.some(f => f.filePath.includes('xdg-rule.md'))).toBe(false);
+ });
+
+ it('should handle missing OPENCODE_CONFIG_DIR/rules gracefully', async () => {
+ const { testDir } = getTestDirs();
+ const customDir = path.join(testDir, 'no-rules-here');
+ mkdirSync(customDir, { recursive: true });
+
+ process.env.OPENCODE_CONFIG_DIR = customDir;
+
+ const files = await discoverRuleFiles();
+ expect(files).toEqual([]);
+ });
});
describe('project rules discovery', () => {
diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts
index 8086dc4..02ba707 100644
--- a/src/index.runtime.test.ts
+++ b/src/index.runtime.test.ts
@@ -4,7 +4,7 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import path from 'path';
-import { writeFileSync, mkdirSync } from 'fs';
+import { writeFileSync, mkdirSync, readdirSync } from 'fs';
import {
setupTestDirs,
teardownTestDirs,
@@ -26,6 +26,10 @@ import * as sessionStoreModule from './session-store.js';
import * as runtimeContextModule from './runtime-context.js';
import * as runtimeChatModule from './runtime-chat.js';
import { __testOnly } from './index.js';
+import {
+ _setStateDirForTesting,
+ readActiveRulesState,
+} from './active-rules-state.js';
describe('module boundary tests', () => {
it('should re-export discoverRuleFiles from rule-discovery module', () => {
@@ -162,10 +166,13 @@ describe('module boundary tests', () => {
describe('OpenCodeRulesPlugin', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
});
afterEach(() => {
@@ -177,11 +184,17 @@ describe('OpenCodeRulesPlugin', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
- it('should export a default plugin function', async () => {
- const { default: plugin } = await import('./index.js');
- expect(typeof plugin).toBe('function');
+ it('should export a plugin module with id and server', async () => {
+ const { default: pluginModule } = await import('./index.js');
+ expect(pluginModule).toHaveProperty('id', 'opencode-rules');
+ expect(typeof pluginModule.server).toBe('function');
});
it('should return transform hooks even when no rules exist', async () => {
@@ -191,7 +204,9 @@ describe('OpenCodeRulesPlugin', () => {
recursive: true,
});
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({
testDir: path.join(testDir, 'empty-project'),
});
@@ -212,7 +227,9 @@ describe('OpenCodeRulesPlugin', () => {
writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Test Rule');
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -230,7 +247,9 @@ describe('OpenCodeRulesPlugin', () => {
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -246,8 +265,8 @@ describe('OpenCodeRulesPlugin', () => {
);
expect(result.system).toContain('You are a helpful assistant.');
- expect(result.system).toContain('OpenCode Rules');
- expect(result.system).toContain('Test Rule');
+ expect(result.system).toContain('# Test Rule');
+ expect(result.system).toContain('Do this always');
});
it('should append rules to existing system prompt', async () => {
@@ -255,7 +274,9 @@ describe('OpenCodeRulesPlugin', () => {
writeFileSync(path.join(globalRulesDir, 'rule.md'), '# My Rule');
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -279,7 +300,9 @@ describe('OpenCodeRulesPlugin', () => {
writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Rule Content');
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -291,8 +314,7 @@ describe('OpenCodeRulesPlugin', () => {
) => Promise<{ system: string }>;
const result = await systemTransform({}, { system: '' });
- expect(result.system).toContain('OpenCode Rules');
- expect(result.system).toContain('Rule Content');
+ expect(result.system).toContain('# Rule Content');
});
it('should not modify messages in messages.transform hook', async () => {
@@ -300,7 +322,9 @@ describe('OpenCodeRulesPlugin', () => {
writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Rule');
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const originalMessages = [
@@ -324,7 +348,9 @@ describe('OpenCodeRulesPlugin', () => {
it('seeds session state once from messages.transform and does not rescan', async () => {
const { testDir } = getTestDirs();
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -362,10 +388,13 @@ describe('OpenCodeRulesPlugin', () => {
describe('SessionState', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
});
afterEach(async () => {
@@ -377,6 +406,11 @@ describe('SessionState', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('prunes session state when over limit', async () => {
@@ -395,7 +429,9 @@ describe('SessionState', () => {
it('updates lastUserPrompt from chat.message', async () => {
const { testDir } = getTestDirs();
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -422,7 +458,10 @@ describe('SessionState', () => {
it('extracts text from mixed parts using shared extraction logic', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -453,7 +492,10 @@ describe('SessionState', () => {
it('stores lastModelID from chat.message for user messages', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -478,7 +520,10 @@ describe('SessionState', () => {
it('stores lastAgentType from chat.message for user messages', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -503,7 +548,10 @@ describe('SessionState', () => {
it('stores both model and agent from chat.message', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -533,7 +581,10 @@ describe('SessionState', () => {
it('does not update model/agent for non-user messages', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -575,7 +626,10 @@ describe('SessionState', () => {
it('updates model/agent on subsequent user messages', async () => {
const { testDir } = getTestDirs();
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
@@ -624,7 +678,9 @@ describe('SessionState', () => {
`---\nglobs:\n - "src/components/**/*.tsx"\n---\n\nUse React best practices.`
);
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -661,7 +717,10 @@ describe('SessionState', () => {
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin, __testOnly } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ __testOnly,
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -685,6 +744,152 @@ describe('SessionState', () => {
});
});
+describe('Active rules state persistence', () => {
+ let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
+ let stateDir: string;
+
+ beforeEach(() => {
+ setupTestDirs();
+ savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
+ const { testDir } = getTestDirs();
+ stateDir = path.join(testDir, 'state');
+ mkdirSync(stateDir, { recursive: true });
+ _setStateDirForTesting(stateDir);
+ });
+
+ afterEach(async () => {
+ teardownTestDirs();
+ _setStateDirForTesting(null);
+ const { __testOnly } = await import('./index.js');
+ __testOnly.resetSessionState();
+ if (savedEnvXDG === undefined) {
+ delete process.env.XDG_CONFIG_HOME;
+ } else {
+ process.env.XDG_CONFIG_HOME = savedEnvXDG;
+ }
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
+ });
+
+ it('writes matched rule paths to state file when rules match', async () => {
+ const { testDir, globalRulesDir } = getTestDirs();
+ const rulePath = path.join(globalRulesDir, 'always-apply.md');
+ writeFileSync(rulePath, '# Always Apply\nThis rule always applies.');
+ process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
+
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
+ const mockInput = createMockPluginInput({ testDir });
+ const hooks = await plugin(
+ mockInput as unknown as Parameters[0]
+ );
+
+ const sessionID = 'ses-state-match';
+ const systemTransform = hooks['experimental.chat.system.transform'] as (
+ input: { sessionID?: string },
+ output: { system: string }
+ ) => Promise<{ system: string }>;
+
+ const result = await systemTransform(
+ { sessionID },
+ { system: 'Base prompt.' }
+ );
+
+ expect(result.system).toContain('Always Apply');
+
+ // Wait for fire-and-forget write to complete
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const state = await readActiveRulesState(sessionID);
+ expect(state).not.toBeNull();
+ expect(state?.sessionId).toBe(sessionID);
+ expect(state?.matchedRulePaths).toHaveLength(1);
+ expect(state?.matchedRulePaths[0]).toBe(rulePath);
+ });
+
+ it('writes empty matchedPaths to state file when no rules match', async () => {
+ const { testDir, globalRulesDir } = getTestDirs();
+ const rulePath = path.join(globalRulesDir, 'conditional.mdc');
+ writeFileSync(
+ rulePath,
+ `---
+model:
+ - gpt-5
+---
+
+Conditional rule for gpt-5 only.`
+ );
+ process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
+
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
+ const mockInput = createMockPluginInput({ testDir });
+ const hooks = await plugin(
+ mockInput as unknown as Parameters[0]
+ );
+
+ const sessionID = 'ses-state-nomatch';
+ const systemTransform = hooks['experimental.chat.system.transform'] as (
+ input: { sessionID?: string },
+ output: { system: string }
+ ) => Promise<{ system: string }>;
+
+ const result = await systemTransform(
+ { sessionID },
+ { system: 'Base prompt.' }
+ );
+
+ // No rules should match (model is not gpt-5)
+ expect(result.system).not.toContain('Conditional rule');
+
+ // Wait for fire-and-forget write to complete
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const state = await readActiveRulesState(sessionID);
+ expect(state).not.toBeNull();
+ expect(state?.sessionId).toBe(sessionID);
+ expect(state?.matchedRulePaths).toHaveLength(0);
+ });
+
+ it('does not write state when sessionID is missing', async () => {
+ const { testDir, globalRulesDir } = getTestDirs();
+ writeFileSync(path.join(globalRulesDir, 'rule.md'), '# Test Rule\nContent');
+ process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
+
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
+ const mockInput = createMockPluginInput({ testDir });
+ const hooks = await plugin(
+ mockInput as unknown as Parameters[0]
+ );
+
+ const systemTransform = hooks['experimental.chat.system.transform'] as (
+ input: { sessionID?: string },
+ output: { system: string }
+ ) => Promise<{ system: string }>;
+
+ // Call without sessionID
+ await systemTransform({}, { system: 'Base prompt.' });
+
+ // Wait briefly
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ // Verify no state files were created in the state directory
+ const files = readdirSync(stateDir);
+ const jsonFiles = files.filter(f => f.endsWith('.json'));
+ expect(jsonFiles).toHaveLength(0);
+ });
+});
+
describe('utils runtime exports', () => {
it('exports only expected functions at runtime', () => {
const exportedKeys = Object.keys(utilsModule).sort();
@@ -710,11 +915,14 @@ describe('session-store runtime exports', () => {
describe('CI environment detection', () => {
let savedCiEnv: CiEnvSnapshot;
let savedXDG: string | undefined;
+ let savedConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedCiEnv = saveCiEnvVars();
savedXDG = process.env.XDG_CONFIG_HOME;
+ savedConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
});
afterEach(async () => {
@@ -727,6 +935,11 @@ describe('CI environment detection', () => {
} else {
process.env.XDG_CONFIG_HOME = savedXDG;
}
+ if (savedConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedConfigDir;
+ }
});
it('should include ci-conditional rule when CI env var is set', async () => {
@@ -740,7 +953,9 @@ describe('CI environment detection', () => {
clearCiEnvVars();
process.env.CI = 'true';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -766,7 +981,9 @@ describe('CI environment detection', () => {
clearCiEnvVars();
process.env.CI = 'false';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -792,7 +1009,9 @@ describe('CI environment detection', () => {
clearCiEnvVars();
process.env.CI = '0';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -818,7 +1037,9 @@ describe('CI environment detection', () => {
clearCiEnvVars();
process.env.GITHUB_ACTIONS = 'true';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
@@ -844,7 +1065,9 @@ describe('CI environment detection', () => {
clearCiEnvVars();
process.env.BUILD_NUMBER = 'false';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockInput = createMockPluginInput({ testDir });
const hooks = await plugin(
mockInput as unknown as Parameters[0]
diff --git a/src/index.test.ts b/src/index.test.ts
index 815efa1..ee1bc15 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -31,11 +31,14 @@ import {
// Retained plugin-level tests with complex runtime filter context
describe('Runtime filter context integration (plugin-level)', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
let savedCiEnv: CiEnvSnapshot;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
savedCiEnv = saveCiEnvVars();
clearRuleCache();
});
@@ -50,6 +53,11 @@ describe('Runtime filter context integration (plugin-level)', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('should include model-conditional rule when session has matching modelID', async () => {
@@ -65,7 +73,9 @@ Model-specific guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -113,7 +123,9 @@ Agent-specific guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -161,7 +173,9 @@ Planning guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -210,7 +224,9 @@ Platform-specific guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -245,7 +261,9 @@ CI-authoritative guidelines.`
process.env.CI = 'false';
process.env.GITHUB_ACTIONS = 'true';
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -283,7 +301,9 @@ All dimensions must match.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -340,7 +360,9 @@ All dimensions must match.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -396,7 +418,9 @@ Node.js project guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -435,7 +459,9 @@ Feature branch guidelines.`
.mockResolvedValue('feature/add-login');
try {
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -470,7 +496,9 @@ Feature branch guidelines.`
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = {
tool: {
ids: vi.fn(async () => {
@@ -509,7 +537,9 @@ Feature branch guidelines.`
);
process.env.XDG_CONFIG_HOME = path.join(testDir, '.config');
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -540,7 +570,9 @@ Feature branch guidelines.`
const nonGitDir = path.join(testDir, 'not-a-git-repo');
mkdirSync(nonGitDir, { recursive: true });
- const { default: plugin } = await import('./index.js');
+ const {
+ default: { server: plugin },
+ } = await import('./index.js');
const mockClient = { tool: { ids: vi.fn(async () => ({ data: [] })) } };
const hooks = await plugin({
client: mockClient as unknown,
@@ -564,10 +596,13 @@ Feature branch guidelines.`
// Retained API contract tests
describe('readAndFormatRules API contract', () => {
let savedEnvXDG: string | undefined;
+ let savedEnvConfigDir: string | undefined;
beforeEach(() => {
setupTestDirs();
savedEnvXDG = process.env.XDG_CONFIG_HOME;
+ savedEnvConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ delete process.env.OPENCODE_CONFIG_DIR;
clearRuleCache();
});
@@ -578,6 +613,11 @@ describe('readAndFormatRules API contract', () => {
} else {
process.env.XDG_CONFIG_HOME = savedEnvXDG;
}
+ if (savedEnvConfigDir === undefined) {
+ delete process.env.OPENCODE_CONFIG_DIR;
+ } else {
+ process.env.OPENCODE_CONFIG_DIR = savedEnvConfigDir;
+ }
});
it('should only accept RuleFilterContext object as second argument', async () => {
@@ -593,13 +633,13 @@ tools:
Tools rule.`
);
- const formatted = await readAndFormatRules(toRules([rulePath]), {
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), {
userPrompt: 'some prompt',
availableToolIDs: ['mcp_websearch', 'mcp_bash'],
});
- expect(formatted).toContain('tools-rule.mdc');
- expect(formatted).toContain('Tools rule');
+ expect(formattedRules).toContain('tools-rule.mdc');
+ expect(formattedRules).toContain('Tools rule');
});
it('should ignore array passed as second arg (legacy positional pattern rejected)', async () => {
@@ -616,11 +656,11 @@ Legacy globs rule.`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const formatted = await readAndFormatRules(toRules([rulePath]), [
+ const { formattedRules } = await readAndFormatRules(toRules([rulePath]), [
'src/app.ts',
] as any);
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
it('should ignore third positional arg (legacy userPrompt rejected)', async () => {
@@ -637,13 +677,13 @@ Legacy keywords rule.`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const formatted = await (readAndFormatRules as any)(
+ const { formattedRules } = await (readAndFormatRules as any)(
toRules([rulePath]),
{},
'help with testing'
);
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
it('should ignore fourth positional arg (legacy availableToolIDs rejected)', async () => {
@@ -660,13 +700,13 @@ Legacy tools rule.`
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- const formatted = await (readAndFormatRules as any)(
+ const { formattedRules } = await (readAndFormatRules as any)(
toRules([rulePath]),
{},
undefined,
['mcp_websearch', 'mcp_bash']
);
- expect(formatted).toBe('');
+ expect(formattedRules).toBe('');
});
});
diff --git a/src/index.ts b/src/index.ts
index 112550d..7d82167 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -4,7 +4,7 @@
* Discovers markdown rule files and injects them into the system prompt.
*/
-import type { PluginInput } from '@opencode-ai/plugin';
+import type { Plugin, PluginInput } from '@opencode-ai/plugin';
import { discoverRuleFiles } from './utils.js';
import { OpenCodeRulesRuntime } from './runtime.js';
import { createSessionStore, type SessionState } from './session-store.js';
@@ -62,5 +62,7 @@ const __testOnly = Object.freeze(
})
);
-export default openCodeRulesPlugin;
+const id = 'opencode-rules' as const;
+const server = openCodeRulesPlugin satisfies Plugin;
+export default { id, server };
export { __testOnly };
diff --git a/src/rule-discovery.ts b/src/rule-discovery.ts
index 3148ce5..d726cb3 100644
--- a/src/rule-discovery.ts
+++ b/src/rule-discovery.ts
@@ -5,12 +5,12 @@
import { stat, readFile, readdir } from 'fs/promises';
import path from 'path';
import os from 'os';
-import { createDebugLog } from './debug.js';
+import { createDebugLog } from './debug';
import {
parseRuleMetadata,
stripFrontmatter,
type RuleMetadata,
-} from './rule-metadata.js';
+} from './rule-metadata';
const debugLog = createDebugLog();
@@ -91,6 +91,11 @@ export async function getCachedRule(
* Get the global rules directory path
*/
function getGlobalRulesDir(): string | null {
+ const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR;
+ if (opencodeConfigDir) {
+ return path.join(opencodeConfigDir, 'rules');
+ }
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
if (xdgConfigHome) {
return path.join(xdgConfigHome, 'opencode', 'rules');
@@ -160,6 +165,7 @@ export interface DiscoveredRule {
/**
* Discover markdown rule files from standard directories
* Searches recursively in:
+ * - $OPENCODE_CONFIG_DIR/rules/ (highest priority)
* - $XDG_CONFIG_HOME/opencode/rules/ (or ~/.config/opencode/rules as fallback)
* - .opencode/rules/ (in project directory if provided)
* Finds all .md and .mdc files including nested subdirectories.
diff --git a/src/rule-filter.ts b/src/rule-filter.ts
index c65e051..ec872fa 100644
--- a/src/rule-filter.ts
+++ b/src/rule-filter.ts
@@ -5,6 +5,7 @@
import { minimatch } from 'minimatch';
import { createDebugLog } from './debug.js';
import { getCachedRule, type DiscoveredRule } from './rule-discovery.js';
+import { type InjectMode } from './rule-metadata.js';
const debugLog = createDebugLog();
@@ -59,6 +60,15 @@ export function toolsMatchAvailable(
return requiredTools.some(tool => availableSet.has(tool));
}
+/**
+ * Result of reading and formatting rules
+ */
+export interface FilterResult {
+ formattedRules: string;
+ matchedPaths: string[];
+ individualContents: string[];
+}
+
/**
* Runtime filter context for conditional rule matching
*/
@@ -86,19 +96,35 @@ export interface RuleFilterContext {
}
/**
- * Read and format rule files for system prompt injection
+ * Options for readAndFormatRules
+ */
+export interface ReadAndFormatOptions {
+ /** When true, omit all headers and separators — only concatenate raw rule content */
+ raw?: boolean;
+ /** Maximum characters for the formatted output. Excess rules are dropped from the end. */
+ maxChars?: number;
+}
+
+/**
+ * Read and format rule files for injection.
* @param files - Array of discovered rule files with paths
* @param context - Optional RuleFilterContext for conditional rule matching
+ * @param injectMode - Filter by injection mode (system, user, both)
+ * @param opts - Formatting options (raw mode, character limit)
*/
export async function readAndFormatRules(
files: DiscoveredRule[],
- context: RuleFilterContext = {}
-): Promise {
+ context: RuleFilterContext = {},
+ injectMode?: InjectMode,
+ opts?: ReadAndFormatOptions
+): Promise {
if (files.length === 0) {
- return '';
+ return { formattedRules: '', matchedPaths: [], individualContents: [] };
}
const ruleContents: string[] = [];
+ const matchedPaths: string[] = [];
+ const individualContents: string[] = [];
const availableToolSet =
context.availableToolIDs && context.availableToolIDs.length > 0
? new Set(context.availableToolIDs)
@@ -241,17 +267,58 @@ export async function readAndFormatRules(
);
}
+ // Filter by injection mode
+ if (injectMode) {
+ const ruleMode = metadata?.inject ?? 'system';
+ if (injectMode === 'system' && ruleMode === 'user') continue;
+ if (injectMode === 'user' && ruleMode === 'system') continue;
+ }
+
// Use cached stripped content for output
- // Use relativePath for unique headings instead of just filename
- ruleContents.push(`## ${relativePath}\n\n${strippedContent}`);
+ const entry = opts?.raw
+ ? strippedContent
+ : `## ${relativePath}\n\n${strippedContent}`;
+ ruleContents.push(entry);
+ matchedPaths.push(filePath);
+ individualContents.push(strippedContent);
}
if (ruleContents.length === 0) {
- return '';
+ return { formattedRules: '', matchedPaths: [], individualContents: [] };
+ }
+
+ let formattedRules: string;
+ if (opts?.raw) {
+ formattedRules = ruleContents.join('\n\n');
+ } else {
+ formattedRules =
+ `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
+ ruleContents.join('\n\n---\n\n');
+ }
+
+ // Truncate to respect maxChars by dropping rules from the end
+ if (opts?.maxChars && formattedRules.length > opts.maxChars) {
+ while (ruleContents.length > 1) {
+ ruleContents.pop();
+ matchedPaths.pop();
+ individualContents.pop();
+ formattedRules = opts?.raw
+ ? ruleContents.join('\n\n')
+ : `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
+ ruleContents.join('\n\n---\n\n');
+ if (formattedRules.length <= opts.maxChars) break;
+ }
+ // If even a single rule exceeds the limit, include it anyway (better than nothing)
+ if (formattedRules.length > opts.maxChars && ruleContents.length === 1) {
+ debugLog(
+ `Single rule exceeds maxChars limit (${formattedRules.length} > ${opts.maxChars})`
+ );
+ }
}
- return (
- `# OpenCode Rules\n\nPlease follow the following rules:\n\n` +
- ruleContents.join('\n\n---\n\n')
- );
+ return {
+ formattedRules,
+ matchedPaths,
+ individualContents,
+ };
}
diff --git a/src/rule-metadata.ts b/src/rule-metadata.ts
index fd644da..1a931c7 100644
--- a/src/rule-metadata.ts
+++ b/src/rule-metadata.ts
@@ -2,11 +2,13 @@
* Rule metadata parsing and frontmatter extraction
*/
-import { parse as parseYaml } from 'yaml';
+const { parse: parseYaml } = await import('yaml');
/**
* Metadata extracted from .mdc file frontmatter
*/
+
+export type InjectMode = 'system' | 'user' | 'both';
export interface RuleMetadata {
globs?: string[];
keywords?: string[];
@@ -19,6 +21,8 @@ export interface RuleMetadata {
os?: string[];
ci?: boolean;
match?: 'any' | 'all';
+ inject?: InjectMode;
+ repeat_every?: number | Record;
}
/**
@@ -36,6 +40,8 @@ interface ParsedFrontmatter {
os?: unknown;
ci?: unknown;
match?: unknown;
+ inject?: unknown;
+ repeat_every?: unknown;
}
/** Field names in ParsedFrontmatter that are string arrays */
@@ -129,6 +135,36 @@ export function parseRuleMetadata(content: string): RuleMetadata | undefined {
metadata.match = parsed.match;
}
+ // Extract inject
+ if (
+ parsed.inject === 'system' ||
+ parsed.inject === 'user' ||
+ parsed.inject === 'both'
+ ) {
+ metadata.inject = parsed.inject;
+ }
+
+ // Extract repeat_every
+ if (typeof parsed.repeat_every === 'number') {
+ if (Number.isInteger(parsed.repeat_every) && parsed.repeat_every >= 1) {
+ metadata.repeat_every = parsed.repeat_every;
+ }
+ } else if (
+ typeof parsed.repeat_every === 'object' &&
+ parsed.repeat_every !== null &&
+ !Array.isArray(parsed.repeat_every)
+ ) {
+ const dict: Record = {};
+ for (const [key, value] of Object.entries(parsed.repeat_every)) {
+ if (typeof value === 'number' && Number.isInteger(value) && value >= 1) {
+ dict[key] = value;
+ }
+ }
+ if (Object.keys(dict).length > 0) {
+ metadata.repeat_every = dict;
+ }
+ }
+
// Return metadata only if it has content
return Object.keys(metadata).length > 0 ? metadata : undefined;
} catch (error) {
diff --git a/src/runtime-chat.ts b/src/runtime-chat.ts
index 7f12af7..5bd216e 100644
--- a/src/runtime-chat.ts
+++ b/src/runtime-chat.ts
@@ -51,6 +51,8 @@ export function handleChatMessage(
sessionStore.upsert(sessionID, state => {
if (userPrompt) {
state.lastUserPrompt = userPrompt;
+ state.turnCount = (state.turnCount ?? 0) + 1;
+ state.turnCount = (state.turnCount ?? 0) + 1;
}
if (input.model?.modelID) {
diff --git a/src/runtime.ts b/src/runtime.ts
index 33bddd9..e56c757 100644
--- a/src/runtime.ts
+++ b/src/runtime.ts
@@ -1,6 +1,7 @@
+import { minimatch } from 'minimatch';
import { readAndFormatRules, type RuleFilterContext } from './rule-filter.js';
+import { type DiscoveredRule, getCachedRule } from './rule-discovery.js';
import { extractFilePathsFromMessages } from './message-paths.js';
-import { type DiscoveredRule } from './rule-discovery.js';
import {
extractLatestUserPrompt,
extractSessionID,
@@ -21,6 +22,7 @@ import {
type ChatMessageInput,
type ChatMessageOutput,
} from './runtime-chat.js';
+import { writeActiveRulesState } from './active-rules-state.js';
interface MessagesTransformOutput {
messages: MessageWithInfo[];
@@ -44,6 +46,35 @@ interface OpenCodeRulesRuntimeOptions {
now?: () => number;
}
+function resolveRepeatEvery(
+ config: number | Record | undefined,
+ modelID: string | undefined
+): number {
+ if (config === undefined) return 1;
+ if (typeof config === 'number') return config;
+ if (modelID && typeof config === 'object') {
+ for (const [pattern, interval] of Object.entries(config)) {
+ if (pattern === 'default') continue;
+ if (minimatch(modelID, pattern)) return interval;
+ }
+ }
+ return config['default'] ?? 1;
+}
+
+/**
+ * Fast non-cryptographic hash for comparing rule content.
+ * Used to avoid re-injecting identical rules and preserve KV-cache.
+ */
+function hashString(s: string): string {
+ let hash = 0;
+ for (let i = 0; i < s.length; i++) {
+ const chr = s.charCodeAt(i);
+ hash = ((hash << 5) - hash) + chr;
+ hash |= 0;
+ }
+ return (hash >>> 0).toString(36);
+}
+
export class OpenCodeRulesRuntime {
private client: unknown;
private directory: string;
@@ -128,40 +159,42 @@ export class OpenCodeRulesRuntime {
}
const existingState = this.sessionStore.get(sessionID);
- if (existingState && existingState.seededFromHistory) {
- this.debugLog(`Session ${sessionID} already seeded, skipping rescan`);
- return output;
- }
- const contextPaths = extractFilePathsFromMessages(
- toExtractableMessages(output.messages)
- );
- const userPrompt = extractLatestUserPrompt(output.messages);
+ if (!existingState || !existingState.seededFromHistory) {
+ const contextPaths = extractFilePathsFromMessages(
+ toExtractableMessages(output.messages)
+ );
+ const userPrompt = extractLatestUserPrompt(output.messages);
- this.sessionStore.upsert(sessionID, state => {
- for (const p of contextPaths) {
- state.contextPaths.add(normalizeContextPath(p, this.projectDirectory));
+ this.sessionStore.upsert(sessionID, state => {
+ for (const p of contextPaths) {
+ state.contextPaths.add(normalizeContextPath(p, this.projectDirectory));
+ }
+ if (userPrompt && !state.lastUserPrompt) {
+ state.lastUserPrompt = userPrompt;
+ }
+ state.seededFromHistory = true;
+ state.seedCount = (state.seedCount ?? 0) + 1;
+ });
+
+ if (contextPaths.length > 0) {
+ this.debugLog(
+ `Seeded ${contextPaths.length} context path(s) for session ${sessionID}: ${contextPaths
+ .slice(0, 5)
+ .join(', ')}${contextPaths.length > 5 ? '...' : ''}`
+ );
}
- if (userPrompt && !state.lastUserPrompt) {
- state.lastUserPrompt = userPrompt;
+
+ if (userPrompt) {
+ this.debugLog(
+ `Seeded user prompt for session ${sessionID} (len=${userPrompt.length})`
+ );
}
- state.seededFromHistory = true;
- state.seedCount = (state.seedCount ?? 0) + 1;
- });
- if (contextPaths.length > 0) {
- this.debugLog(
- `Seeded ${contextPaths.length} context path(s) for session ${sessionID}: ${contextPaths
- .slice(0, 5)
- .join(', ')}${contextPaths.length > 5 ? '...' : ''}`
- );
+ return output;
}
- if (userPrompt) {
- this.debugLog(
- `Seeded user prompt for session ${sessionID} (len=${userPrompt.length})`
- );
- }
+ await this.maybeInjectUserRules(output, sessionID);
return output;
}
@@ -173,6 +206,109 @@ export class OpenCodeRulesRuntime {
handleChatMessage(input, output, this.sessionStore, this.debugLog);
}
+ private async buildCurrentFilterContext(
+ sessionID: string
+ ): Promise {
+ const sessionState = sessionID ? this.sessionStore.get(sessionID) : undefined;
+
+ const contextPaths = sessionState
+ ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b))
+ : [];
+
+ const filterContextOpts: BuildFilterContextOptions = {
+ contextFilePaths: contextPaths,
+ userPrompt: sessionState?.lastUserPrompt,
+ availableToolIDs: await this.queryAvailableToolIDs(),
+ modelID: sessionState?.lastModelID,
+ agentType: sessionState?.lastAgentType,
+ };
+
+ return buildFilterContext(
+ filterContextOpts,
+ this.projectDirectory,
+ this.debugLog
+ );
+ }
+
+ private async maybeInjectUserRules(
+ output: MessagesTransformOutput,
+ sessionID: string
+ ): Promise {
+ const state = this.sessionStore.get(sessionID);
+ if (!state) return;
+
+ const turnCount = state.turnCount ?? 0;
+ if (turnCount === 0) return;
+
+ const filterContext = await this.buildCurrentFilterContext(sessionID);
+ const minInterval = await this.computeMinUserRepeatEvery(filterContext);
+ if (minInterval === undefined) return;
+
+ const lastInject = state.lastUserInjectTurn ?? 0;
+ if (turnCount - lastInject < minInterval) return;
+
+ const { formattedRules } = await readAndFormatRules(
+ this.ruleFiles,
+ filterContext,
+ 'user',
+ { raw: true }
+ );
+
+ if (!formattedRules) return;
+
+ let lastUserIdx = -1;
+ for (let i = output.messages.length - 1; i >= 0; i--) {
+ if (output.messages[i]?.role === 'user') {
+ lastUserIdx = i;
+ break;
+ }
+ }
+
+ if (lastUserIdx >= 0) {
+ output.messages.splice(lastUserIdx, 0, {
+ role: 'user',
+ parts: [{
+ type: 'text',
+ text: formattedRules,
+ synthetic: true,
+ }],
+ });
+
+ this.sessionStore.upsert(sessionID, s => {
+ s.lastUserInjectTurn = turnCount;
+ });
+
+ this.debugLog(
+ `Injected user-prompt rules for session ${sessionID} at turn ${turnCount}`
+ );
+ }
+ }
+
+ private async computeMinUserRepeatEvery(
+ filterContext: RuleFilterContext
+ ): Promise {
+ let minInterval: number | undefined;
+
+ for (const ruleFile of this.ruleFiles) {
+ const cached = await getCachedRule(ruleFile.filePath);
+ if (!cached?.metadata) continue;
+
+ const injectMode = cached.metadata.inject ?? 'system';
+ if (injectMode !== 'user' && injectMode !== 'both') continue;
+
+ const interval = resolveRepeatEvery(
+ cached.metadata.repeat_every,
+ filterContext.modelID
+ );
+ minInterval =
+ minInterval === undefined
+ ? interval
+ : Math.min(minInterval, interval);
+ }
+
+ return minInterval;
+ }
+
private async onSystemTransform(
hookInput: SystemTransformInput,
output: SystemTransformOutput | null
@@ -196,51 +332,98 @@ export class OpenCodeRulesRuntime {
}
}
- const contextPaths = sessionState
- ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b))
- : [];
- const userPrompt = sessionState?.lastUserPrompt;
+ const filterContext = await this.buildCurrentFilterContext(sessionID ?? '');
+ const result = await readAndFormatRules(
+ this.ruleFiles,
+ filterContext,
+ 'system',
+ { raw: true }
+ );
- const availableToolIDs = await this.queryAvailableToolIDs();
+ let { formattedRules, matchedPaths, individualContents } = result;
- const filterContextOpts: BuildFilterContextOptions = {
- contextFilePaths: contextPaths,
- userPrompt,
- availableToolIDs,
- modelID: sessionState?.lastModelID,
- agentType: sessionState?.lastAgentType,
- };
+ if (!formattedRules) {
+ this.debugLog('No applicable rules for current context');
+ if (sessionID) {
+ writeActiveRulesState(sessionID, []);
+ }
+ return output ?? {};
+ }
- const filterContext: RuleFilterContext = await buildFilterContext(
- filterContextOpts,
- this.projectDirectory,
- this.debugLog
- );
+ // Dedup: skip individual rule contents already present in the current system prompt
+ const currentSystemText = output
+ ? Array.isArray(output.system)
+ ? output.system.join('\n')
+ : (output.system ?? '')
+ : '';
+
+ const newContents: string[] = [];
+ const newPaths: string[] = [];
+ for (let i = 0; i < individualContents.length; i++) {
+ const ruleContent = individualContents[i];
+ if (currentSystemText.includes(ruleContent)) {
+ this.debugLog(
+ `Skipping duplicate rule content (already in system prompt): ${matchedPaths[i]}`
+ );
+ } else {
+ newContents.push(ruleContent);
+ newPaths.push(matchedPaths[i]);
+ }
+ }
- const formattedRules = await readAndFormatRules(
- this.ruleFiles,
- filterContext
- );
+ if (newContents.length === 0) {
+ this.debugLog('All rules already present in system prompt - skipping injection');
+ if (sessionID) {
+ writeActiveRulesState(sessionID, []);
+ }
+ return output ?? {};
+ }
- if (!formattedRules) {
- this.debugLog('No applicable rules for current context');
+ // Rebuild formattedRules from non-duplicate contents
+ formattedRules = newContents.join('\n\n');
+ matchedPaths = newPaths;
+
+ if (sessionID) {
+ writeActiveRulesState(sessionID, matchedPaths);
+ }
+
+ const rulesHash = hashString(formattedRules);
+
+ if (sessionState?.lastInjectedRulesHash === rulesHash) {
+ this.debugLog(
+ `Session ${sessionID} rules unchanged - skipping injection to preserve KV-cache`
+ );
return output ?? {};
}
this.debugLog('Injecting rules into system prompt');
if (!output) {
+ if (sessionID) {
+ this.sessionStore.upsert(sessionID, state => {
+ state.rulesInjected = true;
+ state.lastInjectedAt = this.now();
+ state.lastInjectedRulesHash = rulesHash;
+ });
+ }
return { system: formattedRules };
}
if (Array.isArray(output.system)) {
output.system.push(formattedRules);
- return output;
+ } else {
+ output.system = output.system
+ ? `${output.system}\n\n${formattedRules}`
+ : formattedRules;
}
- output.system = output.system
- ? `${output.system}\n\n${formattedRules}`
- : formattedRules;
+ if (sessionID) {
+ this.sessionStore.upsert(sessionID, state => {
+ state.rulesInjected = true;
+ state.lastInjectedAt = this.now();
+ state.lastInjectedRulesHash = rulesHash;
+ });
+ }
return output;
}
@@ -316,6 +499,9 @@ export class OpenCodeRulesRuntime {
}
this.sessionStore.markCompacting(sessionID, this.now());
+ this.sessionStore.upsert(sessionID, s => {
+ delete s.lastInjectedRulesHash;
+ });
const sortedPaths = Array.from(sessionState.contextPaths).sort((a, b) =>
a.localeCompare(b)
diff --git a/src/session-store.test.ts b/src/session-store.test.ts
index 4786ba4..ca694c4 100644
--- a/src/session-store.test.ts
+++ b/src/session-store.test.ts
@@ -43,4 +43,43 @@ describe('SessionStore', () => {
expect(store.shouldSkipInjection('ses_missing', 1234, 30_000)).toBe(true);
});
+
+ it('tracks rulesInjected state', () => {
+ const store = new SessionStore({ max: 100 });
+
+ store.upsert('ses_1', s => void (s.rulesInjected = true));
+
+ expect(store.get('ses_1')?.rulesInjected).toBe(true);
+
+ store.upsert('ses_1', s => void (s.rulesInjected = false));
+
+ expect(store.get('ses_1')?.rulesInjected).toBe(false);
+ });
+
+ it('tracks lastInjectedAt timestamp', () => {
+ const store = new SessionStore({ max: 100 });
+ const now = Date.now();
+
+ store.upsert('ses_1', s => void (s.lastInjectedAt = now));
+
+ expect(store.get('ses_1')?.lastInjectedAt).toBe(now);
+ });
+
+ it('allows rules to be re-injected after new user prompt', () => {
+ const store = new SessionStore({ max: 100 });
+
+ store.upsert('ses_1', s => {
+ s.rulesInjected = true;
+ s.lastInjectedAt = 1000;
+ });
+
+ store.upsert('ses_1', s => {
+ s.lastUserPrompt = 'new message';
+ s.rulesInjected = false;
+ });
+
+ const state = store.get('ses_1');
+ expect(state?.rulesInjected).toBe(false);
+ expect(state?.lastUserPrompt).toBe('new message');
+ });
});
diff --git a/src/session-store.ts b/src/session-store.ts
index e751e1a..1f374d2 100644
--- a/src/session-store.ts
+++ b/src/session-store.ts
@@ -8,6 +8,11 @@ export interface SessionState {
seedCount?: number;
lastModelID?: string;
lastAgentType?: string;
+ rulesInjected?: boolean;
+ lastInjectedAt?: number;
+ turnCount: number;
+ lastUserInjectTurn: number;
+ lastInjectedRulesHash?: string;
}
interface SessionStoreOptions {
@@ -118,6 +123,8 @@ export class SessionStore {
lastUpdated: ++this.tick,
seededFromHistory: false,
seedCount: 0,
+ turnCount: 0,
+ lastUserInjectTurn: 0,
};
}
}
diff --git a/src/test-fixtures.ts b/src/test-fixtures.ts
index ddcf803..d1cfb65 100644
--- a/src/test-fixtures.ts
+++ b/src/test-fixtures.ts
@@ -4,14 +4,14 @@
*/
import path from 'path';
import os from 'os';
-import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'fs';
+import { mkdirSync, mkdtempSync, rmSync } from 'fs';
import type { DiscoveredRule } from './utils.js';
// ============================================================================
// Test Directory Management
// ============================================================================
-export interface TestDirs {
+interface TestDirs {
testDir: string;
globalRulesDir: string;
projectRulesDir: string;
@@ -44,7 +44,7 @@ export function getTestDirs(): TestDirs {
}
// ============================================================================
-// Rule Conversion Helpers
+// Environment Snapshot Helpers
// ============================================================================
/**
@@ -61,7 +61,7 @@ export function toRules(paths: string[]): DiscoveredRule[] {
// CI Environment Helpers
// ============================================================================
-export const CI_ENV_VARS = [
+const CI_ENV_VARS = [
'CI',
'CONTINUOUS_INTEGRATION',
'BUILD_NUMBER',
@@ -101,10 +101,10 @@ export function restoreCiEnvVars(saved: CiEnvSnapshot): void {
}
// ============================================================================
-// Mock Object Builders
+// Environment Snapshot Helpers
// ============================================================================
-export interface MockPluginInput {
+interface MockPluginInput {
testDir: string;
toolIds?: string[];
mcpStatus?: Record;
@@ -151,120 +151,6 @@ export function createMockPluginInput(opts: MockPluginInput): {
};
}
-// ============================================================================
-// Message Part Builders
-// ============================================================================
-
-export interface TextPart {
- type: 'text';
- text: string;
- sessionID?: string;
- synthetic?: boolean;
-}
-
-export interface ToolInvocationPart {
- type: 'tool-invocation';
- toolInvocation: {
- toolName: string;
- args: Record;
- };
- sessionID?: string;
-}
-
-export type MessagePart = TextPart | ToolInvocationPart;
-
-export interface MockMessage {
- role: 'user' | 'assistant';
- parts: MessagePart[];
-}
-
-/**
- * Creates a text message part with optional sessionID.
- */
-export function textPart(text: string, sessionID?: string): TextPart {
- const part: TextPart = { type: 'text', text };
- if (sessionID) part.sessionID = sessionID;
- return part;
-}
-
-/**
- * Creates a tool invocation part for read operations.
- */
-export function readToolPart(
- filePath: string,
- sessionID?: string
-): ToolInvocationPart {
- const part: ToolInvocationPart = {
- type: 'tool-invocation',
- toolInvocation: { toolName: 'read', args: { filePath } },
- };
- if (sessionID) part.sessionID = sessionID;
- return part;
-}
-
-/**
- * Creates a tool invocation part for glob operations.
- */
-export function globToolPart(
- pattern: string,
- sessionID?: string
-): ToolInvocationPart {
- const part: ToolInvocationPart = {
- type: 'tool-invocation',
- toolInvocation: { toolName: 'glob', args: { pattern } },
- };
- if (sessionID) part.sessionID = sessionID;
- return part;
-}
-
-/**
- * Creates a mock message with the given role and parts.
- */
-export function mockMessage(
- role: 'user' | 'assistant',
- parts: MessagePart[]
-): MockMessage {
- return { role, parts };
-}
-
-// ============================================================================
-// Rule File Helpers
-// ============================================================================
-
-/**
- * Writes a rule file with optional YAML frontmatter.
- */
-export function writeRuleFile(
- dir: string,
- filename: string,
- content: string,
- metadata?: Record
-): string {
- const filePath = path.join(dir, filename);
- let fileContent = content;
-
- if (metadata && Object.keys(metadata).length > 0) {
- const yamlLines = ['---'];
- for (const [key, value] of Object.entries(metadata)) {
- if (Array.isArray(value)) {
- yamlLines.push(`${key}:`);
- for (const item of value) {
- yamlLines.push(` - "${item}"`);
- }
- } else if (typeof value === 'boolean') {
- yamlLines.push(`${key}: ${value}`);
- } else {
- yamlLines.push(`${key}: ${value}`);
- }
- }
- yamlLines.push('---', '', content);
- fileContent = yamlLines.join('\n');
- }
-
- writeFileSync(filePath, fileContent);
- return filePath;
-}
-
// ============================================================================
// Environment Snapshot Helpers
// ============================================================================
diff --git a/src/utils.ts b/src/utils.ts
index dae9e4a..7935fdd 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -17,7 +17,7 @@ export {
} from './rule-discovery.js';
// Re-export from rule-metadata (RuleMetadata is internal, not re-exported)
-export { parseRuleMetadata } from './rule-metadata.js';
+export { parseRuleMetadata, type InjectMode } from './rule-metadata.js';
// Re-export from rule-filter
export {
@@ -25,6 +25,8 @@ export {
toolsMatchAvailable,
readAndFormatRules,
type RuleFilterContext,
+ type FilterResult,
+ type ReadAndFormatOptions,
} from './rule-filter.js';
// Re-export from message-paths
diff --git a/tsconfig.json b/tsconfig.json
index 9b75f56..fbe2351 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,8 +4,9 @@
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
+ "jsx": "react-jsx",
"outDir": "./dist",
- "rootDir": "./src",
+ "rootDir": ".",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
@@ -27,6 +28,13 @@
"isolatedModules": true,
"verbatimModuleSyntax": true
},
- "include": ["src/**/*"],
- "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"]
+ "include": ["src/**/*", "tui/**/*"],
+ "exclude": [
+ "node_modules",
+ "dist",
+ "**/*.test.ts",
+ "**/*.spec.ts",
+ "**/*.test.tsx",
+ "**/*.spec.tsx"
+ ]
}
diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts
new file mode 100644
index 0000000..c274ff4
--- /dev/null
+++ b/tui/data/rules.test.ts
@@ -0,0 +1,518 @@
+// tui/data/rules.test.ts
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import path from 'path';
+import os from 'os';
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync, chmodSync } from 'fs';
+import { clearRuleCache } from '../../src/rule-discovery.js';
+import {
+ _setStateDirForTesting,
+ writeActiveRulesState,
+} from '../../src/active-rules-state.js';
+import {
+ ruleSource,
+ hasConditions,
+ formatConditionSummary,
+ disambiguateNames,
+ loadSidebarRules,
+ type SidebarRuleEntry,
+} from './rules.js';
+
+// ──────────────────────────────────────────────
+// ruleSource
+// ──────────────────────────────────────────────
+
+describe('ruleSource', () => {
+ it('returns "global" when projectDir is null', () => {
+ expect(ruleSource('/home/user/.config/opencode/rules/foo.md', null)).toBe(
+ 'global'
+ );
+ });
+
+ it('returns "project" for files under projectDir/.opencode/rules/', () => {
+ expect(ruleSource('/project/.opencode/rules/foo.md', '/project')).toBe(
+ 'project'
+ );
+ });
+
+ it('returns "project" for files in subdirectories under project rules', () => {
+ expect(ruleSource('/project/.opencode/rules/sub/deep.md', '/project')).toBe(
+ 'project'
+ );
+ });
+
+ it('returns "global" for files not under projectDir/.opencode/rules/', () => {
+ expect(
+ ruleSource('/home/user/.config/opencode/rules/foo.md', '/project')
+ ).toBe('global');
+ });
+
+ it('does not match partial path prefixes', () => {
+ // /project/.opencode/rules-extra/ should NOT match /project/.opencode/rules/
+ expect(
+ ruleSource('/project/.opencode/rules-extra/foo.md', '/project')
+ ).toBe('global');
+ });
+});
+
+// ──────────────────────────────────────────────
+// hasConditions
+// ──────────────────────────────────────────────
+
+describe('hasConditions', () => {
+ it('returns false for undefined metadata', () => {
+ expect(hasConditions(undefined)).toBe(false);
+ });
+
+ it('returns false for empty metadata', () => {
+ expect(hasConditions({})).toBe(false);
+ });
+
+ it('returns true when globs is set', () => {
+ expect(hasConditions({ globs: ['**/*.ts'] })).toBe(true);
+ });
+
+ it('returns true when ci is false (still a condition)', () => {
+ expect(hasConditions({ ci: false })).toBe(true);
+ });
+
+ it('returns true for any single field', () => {
+ expect(hasConditions({ keywords: ['test'] })).toBe(true);
+ expect(hasConditions({ tools: ['mcp_bash'] })).toBe(true);
+ expect(hasConditions({ model: ['gpt-5'] })).toBe(true);
+ expect(hasConditions({ agent: ['coder'] })).toBe(true);
+ expect(hasConditions({ command: ['/plan'] })).toBe(true);
+ expect(hasConditions({ project: ['node'] })).toBe(true);
+ expect(hasConditions({ branch: ['main'] })).toBe(true);
+ expect(hasConditions({ os: ['linux'] })).toBe(true);
+ });
+});
+
+// ──────────────────────────────────────────────
+// formatConditionSummary
+// ──────────────────────────────────────────────
+
+describe('formatConditionSummary', () => {
+ it('formats single array field', () => {
+ expect(formatConditionSummary({ globs: ['**/*.ts'] })).toBe(
+ 'globs: **/*.ts'
+ );
+ });
+
+ it('formats multiple array values with commas', () => {
+ expect(formatConditionSummary({ keywords: ['auth', 'security'] })).toBe(
+ 'keywords: auth, security'
+ );
+ });
+
+ it('formats multiple fields with commas', () => {
+ const result = formatConditionSummary({
+ globs: ['**/*.ts'],
+ keywords: ['test'],
+ });
+ expect(result).toBe('globs: **/*.ts, keywords: test');
+ });
+
+ it('includes ci boolean', () => {
+ expect(formatConditionSummary({ ci: true })).toBe('ci: true');
+ });
+
+ it('includes match mode', () => {
+ expect(formatConditionSummary({ model: ['gpt-5'], match: 'all' })).toBe(
+ 'model: gpt-5, match: all'
+ );
+ });
+
+ it('formats all fields in canonical order', () => {
+ const result = formatConditionSummary({
+ os: ['linux'],
+ globs: ['*.md'],
+ ci: false,
+ match: 'all',
+ });
+ expect(result).toBe('globs: *.md, os: linux, ci: false, match: all');
+ });
+});
+
+// ──────────────────────────────────────────────
+// disambiguateNames
+// ──────────────────────────────────────────────
+
+describe('disambiguateNames', () => {
+ it('assigns filename stem for unique names', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'foo.md' }),
+ makeEntry({ path: 'bar.mdc' }),
+ ];
+ disambiguateNames(entries);
+ expect(entries[0]!.name).toBe('foo');
+ expect(entries[1]!.name).toBe('bar');
+ });
+
+ it('adds parent dir prefix for duplicate stems', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'frontend/security.md' }),
+ makeEntry({ path: 'backend/security.md' }),
+ ];
+ disambiguateNames(entries);
+ expect(entries[0]!.name).toBe('frontend/security');
+ expect(entries[1]!.name).toBe('backend/security');
+ });
+
+ it('falls back to full path for triple collisions after parent prefix', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'apps/web/security.mdc' }),
+ makeEntry({ path: 'packages/web/security.mdc' }),
+ makeEntry({ path: 'other/security.md' }),
+ ];
+ disambiguateNames(entries);
+ // web/security appears twice, falls back to full path (with extension) for those
+ expect(entries[0]!.name).toBe('apps/web/security.mdc');
+ expect(entries[1]!.name).toBe('packages/web/security.mdc');
+ // other/security is unique after parent prefix
+ expect(entries[2]!.name).toBe('other/security');
+ });
+
+ it('disambiguates same-directory collisions with different extensions', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'dup.md' }),
+ makeEntry({ path: 'dup.mdc' }),
+ ];
+ disambiguateNames(entries);
+ // Both stem to "dup", no parent dir to prefix (dirname is ".").
+ // Falls back to full path with extension.
+ expect(entries[0]!.name).toBe('dup.md');
+ expect(entries[1]!.name).toBe('dup.mdc');
+ });
+
+ it('handles entries with subdirectory paths and different extensions', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'rules/dup.md' }),
+ makeEntry({ path: 'rules/dup.mdc' }),
+ ];
+ disambiguateNames(entries);
+ // Both stem to "dup", same parent "rules" -> "rules/dup" for both.
+ // Still ambiguous, falls back to full path with extension.
+ expect(entries[0]!.name).toBe('rules/dup.md');
+ expect(entries[1]!.name).toBe('rules/dup.mdc');
+ });
+
+ it('preserves multi-dot filenames correctly', () => {
+ const entries: SidebarRuleEntry[] = [
+ makeEntry({ path: 'my.config.md' }),
+ makeEntry({ path: 'other.md' }),
+ ];
+ disambiguateNames(entries);
+ // lastIndexOf('.') gives "my.config", not "my"
+ expect(entries[0]!.name).toBe('my.config');
+ expect(entries[1]!.name).toBe('other');
+ });
+});
+
+// ──────────────────────────────────────────────
+// loadSidebarRules (integration)
+// ──────────────────────────────────────────────
+
+describe('loadSidebarRules', () => {
+ let testDir: string;
+ let savedXDG: string | undefined;
+ let savedConfigDir: string | undefined;
+
+ beforeEach(() => {
+ testDir = mkdtempSync(path.join(os.tmpdir(), 'tui-rules-test-'));
+ savedXDG = process.env['XDG_CONFIG_HOME'];
+ savedConfigDir = process.env['OPENCODE_CONFIG_DIR'];
+ delete process.env['OPENCODE_CONFIG_DIR'];
+ clearRuleCache();
+ });
+
+ afterEach(() => {
+ rmSync(testDir, { recursive: true, force: true });
+ if (savedXDG === undefined) {
+ delete process.env['XDG_CONFIG_HOME'];
+ } else {
+ process.env['XDG_CONFIG_HOME'] = savedXDG;
+ }
+ if (savedConfigDir === undefined) {
+ delete process.env['OPENCODE_CONFIG_DIR'];
+ } else {
+ process.env['OPENCODE_CONFIG_DIR'] = savedConfigDir;
+ }
+ });
+
+ it('discovers global rules when projectDir is null', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'rule.md'), '# Always');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules, skippedCount } = await loadSidebarRules(null);
+
+ expect(rules).toHaveLength(1);
+ expect(rules[0]!.source).toBe('global');
+ expect(rules[0]!.name).toBe('rule');
+ expect(rules[0]!.isConditional).toBe(false);
+ expect(rules[0]!.conditionSummary).toBe('always active');
+ expect(skippedCount).toBe(0);
+ });
+
+ it('discovers both global and project rules', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ const projDir = path.join(testDir, 'project');
+ const projRulesDir = path.join(projDir, '.opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ mkdirSync(projRulesDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'global.md'), '# Global');
+ writeFileSync(path.join(projRulesDir, 'local.md'), '# Local');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules } = await loadSidebarRules(projDir);
+
+ expect(rules).toHaveLength(2);
+ // Project rules sort first
+ expect(rules[0]!.source).toBe('project');
+ expect(rules[1]!.source).toBe('global');
+ });
+
+ it('parses metadata for conditional rules', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(
+ path.join(globalDir, 'conditional.mdc'),
+ `---\nglobs:\n - "**/*.ts"\nkeywords:\n - testing\n---\nRule content`
+ );
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules } = await loadSidebarRules(null);
+
+ expect(rules).toHaveLength(1);
+ expect(rules[0]!.isConditional).toBe(true);
+ expect(rules[0]!.conditionSummary).toContain('globs');
+ expect(rules[0]!.conditionSummary).toContain('keywords');
+ expect(rules[0]!.metadata.globs).toEqual(['**/*.ts']);
+ });
+
+ it('sorts project rules before global, alphabetical within group', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ const projDir = path.join(testDir, 'project');
+ const projRulesDir = path.join(projDir, '.opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ mkdirSync(projRulesDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'zebra.md'), '# Z');
+ writeFileSync(path.join(globalDir, 'alpha.md'), '# A');
+ writeFileSync(path.join(projRulesDir, 'beta.md'), '# B');
+ writeFileSync(path.join(projRulesDir, 'aardvark.md'), '# AA');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules } = await loadSidebarRules(projDir);
+
+ expect(rules.map(r => `${r.source}:${r.name}`)).toEqual([
+ 'project:aardvark',
+ 'project:beta',
+ 'global:alpha',
+ 'global:zebra',
+ ]);
+ });
+
+ it('increments skippedCount for unreadable files', async () => {
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'readable.md'), '# OK');
+ const unreadable = path.join(globalDir, 'unreadable.md');
+ writeFileSync(unreadable, '# Nope');
+ chmodSync(unreadable, 0o000);
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules, skippedCount } = await loadSidebarRules(null);
+
+ // One readable, one unreadable
+ expect(rules).toHaveLength(1);
+ expect(rules[0]!.name).toBe('readable');
+ expect(skippedCount).toBe(1);
+
+ // getCachedRule() logs its own warning for read failures —
+ // loadSidebarRules does NOT add a second one (no duplicate logs).
+ // But getCachedRule's internal warning should still fire:
+ expect(warnSpy).toHaveBeenCalled();
+
+ // Restore permissions for cleanup
+ chmodSync(unreadable, 0o644);
+ warnSpy.mockRestore();
+ });
+});
+
+// ──────────────────────────────────────────────
+// loadSidebarRules isActive behavior
+// ──────────────────────────────────────────────
+
+describe('loadSidebarRules isActive behavior', () => {
+ let testDir: string;
+ let stateDir: string;
+ let savedXDG: string | undefined;
+ let savedConfigDir: string | undefined;
+
+ beforeEach(() => {
+ testDir = mkdtempSync(path.join(os.tmpdir(), 'tui-rules-active-test-'));
+ stateDir = path.join(testDir, 'state');
+ mkdirSync(stateDir, { recursive: true });
+ savedXDG = process.env['XDG_CONFIG_HOME'];
+ savedConfigDir = process.env['OPENCODE_CONFIG_DIR'];
+ delete process.env['OPENCODE_CONFIG_DIR'];
+ clearRuleCache();
+ _setStateDirForTesting(stateDir);
+ });
+
+ afterEach(() => {
+ _setStateDirForTesting(null);
+ rmSync(testDir, { recursive: true, force: true });
+ if (savedXDG === undefined) {
+ delete process.env['XDG_CONFIG_HOME'];
+ } else {
+ process.env['XDG_CONFIG_HOME'] = savedXDG;
+ }
+ if (savedConfigDir === undefined) {
+ delete process.env['OPENCODE_CONFIG_DIR'];
+ } else {
+ process.env['OPENCODE_CONFIG_DIR'] = savedConfigDir;
+ }
+ });
+
+ it('sets hasEvaluationState to false when no sessionId provided', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'rule.md'), '# Always');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const result = await loadSidebarRules(null);
+
+ expect(result.hasEvaluationState).toBe(false);
+ });
+
+ it('sets hasEvaluationState to false when state file does not exist', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'rule.md'), '# Always');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const result = await loadSidebarRules(null, 'nonexistent-session');
+
+ expect(result.hasEvaluationState).toBe(false);
+ });
+
+ it('sets hasEvaluationState to true when state file exists', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'rule.md'), '# Always');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ writeActiveRulesState('test-session', []);
+ // Wait for async write to complete
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const result = await loadSidebarRules(null, 'test-session');
+
+ expect(result.hasEvaluationState).toBe(true);
+ });
+
+ it('sets isActive to true for unconditional rules without state file', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(path.join(globalDir, 'always.md'), '# Always active');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules } = await loadSidebarRules(null);
+
+ expect(rules[0]!.isConditional).toBe(false);
+ expect(rules[0]!.isActive).toBe(true);
+ });
+
+ it('sets isActive to null for conditional rules without state file', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(
+ path.join(globalDir, 'conditional.mdc'),
+ `---\nglobs:\n - "**/*.ts"\n---\nConditional rule`
+ );
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ const { rules } = await loadSidebarRules(null);
+
+ expect(rules[0]!.isConditional).toBe(true);
+ expect(rules[0]!.isActive).toBe(null);
+ });
+
+ it('sets isActive based on matchedRulePaths when state file exists', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ const matchedPath = path.join(globalDir, 'matched.md');
+ const unmatchedPath = path.join(globalDir, 'unmatched.md');
+ writeFileSync(matchedPath, '# Matched');
+ writeFileSync(unmatchedPath, '# Unmatched');
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ writeActiveRulesState('test-session', [matchedPath]);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const { rules } = await loadSidebarRules(null, 'test-session');
+
+ const matched = rules.find(r => r.name === 'matched');
+ const unmatched = rules.find(r => r.name === 'unmatched');
+
+ expect(matched!.isActive).toBe(true);
+ expect(unmatched!.isActive).toBe(false);
+ });
+
+ it('correctly matches conditional rules with state file', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ const conditionalPath = path.join(globalDir, 'conditional.mdc');
+ writeFileSync(
+ conditionalPath,
+ `---\nglobs:\n - "**/*.ts"\n---\nConditional rule`
+ );
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ // Conditional rule is in matchedRulePaths
+ writeActiveRulesState('test-session', [conditionalPath]);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const { rules } = await loadSidebarRules(null, 'test-session');
+
+ expect(rules[0]!.isConditional).toBe(true);
+ expect(rules[0]!.isActive).toBe(true);
+ });
+
+ it('marks conditional rules as inactive when not in matchedRulePaths', async () => {
+ const globalDir = path.join(testDir, '.config', 'opencode', 'rules');
+ mkdirSync(globalDir, { recursive: true });
+ writeFileSync(
+ path.join(globalDir, 'conditional.mdc'),
+ `---\nglobs:\n - "**/*.ts"\n---\nConditional rule`
+ );
+ process.env['XDG_CONFIG_HOME'] = path.join(testDir, '.config');
+
+ // Empty matchedRulePaths - nothing matched
+ writeActiveRulesState('test-session', []);
+ await new Promise(resolve => setTimeout(resolve, 50));
+
+ const { rules } = await loadSidebarRules(null, 'test-session');
+
+ expect(rules[0]!.isConditional).toBe(true);
+ expect(rules[0]!.isActive).toBe(false);
+ });
+});
+
+/** Helper to create a minimal SidebarRuleEntry for disambiguation tests */
+function makeEntry(
+ overrides: Partial & { path: string }
+): SidebarRuleEntry {
+ return {
+ name: '',
+ path: overrides.path,
+ source: overrides.source ?? 'global',
+ isConditional: overrides.isConditional ?? false,
+ conditionSummary: overrides.conditionSummary ?? 'always active',
+ metadata: overrides.metadata ?? {},
+ isActive: overrides.isActive ?? true,
+ };
+}
diff --git a/tui/data/rules.ts b/tui/data/rules.ts
new file mode 100644
index 0000000..61cbd03
--- /dev/null
+++ b/tui/data/rules.ts
@@ -0,0 +1,230 @@
+// tui/data/rules.ts
+import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery';
+import type { RuleMetadata } from '../../src/rule-metadata';
+import { readActiveRulesState } from '../../src/active-rules-state';
+import path from 'path';
+
+/** Represents a rule as displayed in the sidebar */
+export interface SidebarRuleEntry {
+ /** Display name (filename stem, disambiguated if needed) */
+ name: string;
+ /** Relative file path from the rules directory root */
+ path: string;
+ /** Whether this rule came from global or project-local rules dir */
+ source: 'global' | 'project';
+ /** Whether the rule has any conditional metadata */
+ isConditional: boolean;
+ /** Human-readable condition summary */
+ conditionSummary: string;
+ /** Full metadata for expanded view */
+ metadata: RuleMetadata;
+ /**
+ * Active state of the rule.
+ * - true: rule is active (matched by evaluation or unconditional without state file)
+ * - false: rule is not active (not matched by evaluation)
+ * - null: state not yet determined (conditional rule without state file)
+ */
+ isActive: boolean | null;
+}
+
+export interface LoadSidebarRulesResult {
+ rules: SidebarRuleEntry[];
+ skippedCount: number;
+ /** Whether active rules state was successfully read from disk */
+ hasEvaluationState: boolean;
+}
+
+/**
+ * Load all discovered rules formatted for sidebar display.
+ * Reuses discoverRuleFiles/getCachedRule from the server plugin.
+ *
+ * @param projectDir - Project directory or null (global rules only)
+ * @param sessionId - Optional session ID to read active rules state
+ */
+export async function loadSidebarRules(
+ projectDir: string | null,
+ sessionId?: string
+): Promise {
+ // discoverRuleFiles accepts string | undefined, not null
+ const discovered = await discoverRuleFiles(projectDir ?? undefined);
+
+ // Read active rules state if sessionId provided
+ const activeState = sessionId ? await readActiveRulesState(sessionId) : null;
+ const hasEvaluationState = activeState !== null;
+ const matchedPathsSet = hasEvaluationState
+ ? new Set(activeState.matchedRulePaths)
+ : null;
+
+ const entries: SidebarRuleEntry[] = [];
+ let skippedCount = 0;
+
+ for (const rule of discovered) {
+ const cached = await getCachedRule(rule.filePath);
+ if (!cached) {
+ // getCachedRule() already logs a warning for read failures,
+ // so we only increment the counter here — no duplicate log.
+ skippedCount++;
+ continue;
+ }
+
+ const meta = cached.metadata;
+ const source = ruleSource(rule.filePath, projectDir);
+ const isConditional = hasConditions(meta);
+ const conditionSummary = isConditional
+ ? formatConditionSummary(meta!)
+ : 'always active';
+
+ // Determine isActive based on state file or fallback logic
+ let isActive: boolean | null;
+ if (matchedPathsSet !== null) {
+ // With state file: check if this rule's absolute path is in matchedPaths
+ isActive = matchedPathsSet.has(rule.filePath);
+ } else {
+ // Without state file: unconditional = true, conditional = null
+ isActive = isConditional ? null : true;
+ }
+
+ entries.push({
+ name: '', // placeholder — set in disambiguation pass
+ path: rule.relativePath,
+ source,
+ isConditional,
+ conditionSummary,
+ metadata: meta ?? {},
+ isActive,
+ });
+ }
+
+ disambiguateNames(entries);
+
+ // Sort: project first, then global. Active rules to top, then alpha by name.
+ const activeOrder = (v: boolean | null): number =>
+ v === true ? 0 : v === null ? 1 : 2;
+ entries.sort((a, b) => {
+ if (a.source !== b.source) return a.source === 'project' ? -1 : 1;
+ const activeCmp = activeOrder(a.isActive) - activeOrder(b.isActive);
+ if (activeCmp !== 0) return activeCmp;
+ const nameCompare = a.name.localeCompare(b.name);
+ if (nameCompare !== 0) return nameCompare;
+ return a.path.localeCompare(b.path);
+ });
+
+ return { rules: entries, skippedCount, hasEvaluationState };
+}
+
+/**
+ * Determine if a rule file is project-local or global.
+ * Uses path.sep boundary check to avoid matching partial prefixes
+ * (e.g., /project/.opencode/rules-extra/ should not match).
+ */
+export function ruleSource(
+ filePath: string,
+ projectDir: string | null
+): 'global' | 'project' {
+ if (!projectDir) return 'global';
+ const projectRulesPrefix =
+ path.join(projectDir, '.opencode', 'rules') + path.sep;
+ return filePath.startsWith(projectRulesPrefix) ? 'project' : 'global';
+}
+
+/**
+ * Check if metadata has any conditional fields set.
+ */
+export function hasConditions(meta: RuleMetadata | undefined): boolean {
+ if (!meta) return false;
+ return !!(
+ meta.globs ||
+ meta.keywords ||
+ meta.tools ||
+ meta.model ||
+ meta.agent ||
+ meta.command ||
+ meta.project ||
+ meta.branch ||
+ meta.os ||
+ meta.ci !== undefined
+ );
+}
+
+/**
+ * Build a human-readable, comma-separated summary of active conditions.
+ * E.g., "globs: src/*.ts, keywords: auth, security"
+ */
+export function formatConditionSummary(meta: RuleMetadata): string {
+ const parts: string[] = [];
+
+ const arrayFields: Array<[keyof RuleMetadata, string]> = [
+ ['globs', 'globs'],
+ ['keywords', 'keywords'],
+ ['tools', 'tools'],
+ ['model', 'model'],
+ ['agent', 'agent'],
+ ['command', 'command'],
+ ['project', 'project'],
+ ['branch', 'branch'],
+ ['os', 'os'],
+ ];
+
+ for (const [field, label] of arrayFields) {
+ const value = meta[field];
+ if (Array.isArray(value) && value.length > 0) {
+ parts.push(`${label}: ${(value as string[]).join(', ')}`);
+ }
+ }
+
+ if (meta.ci !== undefined) {
+ parts.push(`ci: ${String(meta.ci)}`);
+ }
+
+ if (meta.match) {
+ parts.push(`match: ${meta.match}`);
+ }
+
+ return parts.join(', ');
+}
+
+/**
+ * Three-pass name disambiguation.
+ * Pass 1: Extract filename stem from each entry's path.
+ * Pass 2: For duplicate stems, prefix with parent directory.
+ * Pass 3: If still ambiguous (same parent or root-level), use full relative
+ * path (including extension) as the display name.
+ *
+ * Mutates entries[].name in place.
+ */
+export function disambiguateNames(entries: SidebarRuleEntry[]): void {
+ // Pass 1: assign stem names (filename without extension, using last dot)
+ for (const entry of entries) {
+ const basename = path.basename(entry.path);
+ const dotIndex = basename.lastIndexOf('.');
+ entry.name = dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
+ }
+
+ // Pass 2: detect and resolve collisions with parent directory prefix
+ const stemCounts = new Map();
+ for (const entry of entries) {
+ stemCounts.set(entry.name, (stemCounts.get(entry.name) ?? 0) + 1);
+ }
+
+ for (const entry of entries) {
+ if ((stemCounts.get(entry.name) ?? 0) <= 1) continue;
+
+ const dir = path.dirname(entry.path);
+ if (dir && dir !== '.') {
+ const parent = path.basename(dir);
+ entry.name = `${parent}/${entry.name}`;
+ }
+ }
+
+ // Pass 3: if still ambiguous, use full relative path WITH extension
+ const nameCounts = new Map();
+ for (const entry of entries) {
+ nameCounts.set(entry.name, (nameCounts.get(entry.name) ?? 0) + 1);
+ }
+
+ for (const entry of entries) {
+ if ((nameCounts.get(entry.name) ?? 0) > 1) {
+ entry.name = entry.path;
+ }
+ }
+}
diff --git a/tui/index.tsx b/tui/index.tsx
new file mode 100644
index 0000000..5364ceb
--- /dev/null
+++ b/tui/index.tsx
@@ -0,0 +1,23 @@
+// tui/index.tsx
+/** @jsxImportSource @opentui/solid */
+import type { TuiPlugin } from '@opencode-ai/plugin/tui';
+import { SidebarContent } from './slots/sidebar-content';
+
+const id = 'opencode-rules' as const;
+
+const tui: TuiPlugin = async api => {
+ api.slots.register({
+ order: 350,
+ slots: {
+ sidebar_content: (ctx, props) => (
+
+ ),
+ },
+ });
+};
+
+export default { id, tui };
diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx
new file mode 100644
index 0000000..cef1135
--- /dev/null
+++ b/tui/slots/sidebar-content.tsx
@@ -0,0 +1,364 @@
+// tui/slots/sidebar-content.tsx
+/** @jsxImportSource @opentui/solid */
+import {
+ createSignal,
+ createEffect,
+ createMemo,
+ onCleanup,
+ Show,
+ For,
+ type JSX,
+} from 'solid-js';
+import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui';
+import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules';
+
+interface SidebarContentProps {
+ sessionId: string;
+ api: TuiPluginApi;
+ theme: TuiTheme;
+}
+
+type ThemeColor = string | import('@opentui/core').RGBA;
+
+interface ThemeColors {
+ text: ThemeColor;
+ textMuted: ThemeColor;
+ success: ThemeColor;
+ [key: string]: unknown;
+}
+
+interface RuleSectionProps {
+ title: string;
+ rules: SidebarRuleEntry[];
+ theme: ThemeColors;
+ open: boolean;
+ onToggle: () => void;
+ expandedIndex: number | null;
+ globalOffset: number;
+ onExpandToggle: (globalIndex: number) => void;
+ hasEvaluationState: boolean;
+}
+
+function RuleSection(props: RuleSectionProps): JSX.Element {
+ const activeCount = createMemo(
+ () => props.rules.filter(r => r.isActive === true).length
+ );
+
+ const headerCount = createMemo(() => {
+ if (props.hasEvaluationState) {
+ return `(${activeCount()}/${props.rules.length})`;
+ }
+ return `(${props.rules.length})`;
+ });
+
+ const bulletColor = (rule: SidebarRuleEntry): ThemeColor => {
+ return rule.isActive === true ? props.theme.success : props.theme.textMuted;
+ };
+
+ return (
+ 0}>
+
+ props.onToggle()}>
+ {props.open ? '▼' : '▶'}
+
+ {props.title}
+
+
+ {' '}
+ {headerCount()}
+
+
+
+
+
+
+ {(rule, localIndex) => {
+ const globalIndex = () => props.globalOffset + localIndex();
+ return (
+ props.onExpandToggle(globalIndex())}
+ >
+
+ •
+ {rule.name}
+
+
+
+ {rule.path}
+ 0}>
+
+ Globs: {rule.metadata.globs!.join(', ')}
+
+
+ 0}>
+
+ Keywords: {rule.metadata.keywords!.join(', ')}
+
+
+ 0}>
+
+ Tools: {rule.metadata.tools!.join(', ')}
+
+
+ 0}>
+
+ Model: {rule.metadata.model!.join(', ')}
+
+
+ 0}>
+
+ Agent: {rule.metadata.agent!.join(', ')}
+
+
+ 0}>
+
+ Command: {rule.metadata.command!.join(', ')}
+
+
+ 0}>
+
+ Project: {rule.metadata.project!.join(', ')}
+
+
+ 0}>
+
+ Branch: {rule.metadata.branch!.join(', ')}
+
+
+ 0}>
+
+ OS: {rule.metadata.os!.join(', ')}
+
+
+
+
+ CI: {String(rule.metadata.ci)}
+
+
+
+
+ Match: {rule.metadata.match}
+
+
+
+
+
+ );
+ }}
+
+
+
+
+ );
+}
+
+export function SidebarContent(props: SidebarContentProps): JSX.Element {
+ const [rules, setRules] = createSignal([]);
+ const [status, setStatus] = createSignal<'loading' | 'loaded' | 'error'>(
+ 'loading'
+ );
+ const [skippedCount, setSkippedCount] = createSignal(0);
+ const [hasEvaluationState, setHasEvaluationState] = createSignal(false);
+ const [expandedIndex, setExpandedIndex] = createSignal(null);
+ const [lastDir, setLastDir] = createSignal(
+ undefined
+ );
+ const [lastSessionId, setLastSessionId] = createSignal(
+ undefined
+ );
+ const [projectOpen, setProjectOpen] = createSignal(false);
+ const [globalOpen, setGlobalOpen] = createSignal(false);
+ const [refreshCounter, setRefreshCounter] = createSignal(0);
+
+ const theme = (): ThemeColors => props.theme.current as ThemeColors;
+
+ const resolveProjectDir = (): string | null => {
+ return props.api.state.path.directory ?? null;
+ };
+
+ // Monotonic counter to detect stale async results
+ let requestId = 0;
+ // Debounce timer for event-driven refresh
+ let debounceTimer: ReturnType | null = null;
+
+ // Initial load: triggered by session/directory change, resets all UI state
+ const loadRulesInitial = async (): Promise => {
+ const thisRequest = ++requestId;
+ const dir = resolveProjectDir();
+ const sessionId = props.sessionId;
+
+ setLastDir(dir);
+ setLastSessionId(sessionId);
+ setStatus('loading');
+
+ try {
+ const result = await loadSidebarRules(dir, sessionId);
+ // Discard if a newer request started
+ if (requestId !== thisRequest) return;
+ setRules(result.rules);
+ setSkippedCount(result.skippedCount);
+ setHasEvaluationState(result.hasEvaluationState);
+ setStatus('loaded');
+ } catch (err) {
+ // Discard if a newer request started
+ if (requestId !== thisRequest) return;
+ console.error('[opencode-rules] Failed to load rules:', err);
+ setStatus('error');
+ }
+ };
+
+ // Refresh load: triggered by events, only updates rule data (no UI state reset)
+ const loadRulesRefresh = async (): Promise => {
+ // Skip refresh if initial load is still in flight
+ if (status() === 'loading') return;
+
+ const thisRequest = ++requestId;
+ const dir = resolveProjectDir();
+ const sessionId = props.sessionId;
+
+ try {
+ const result = await loadSidebarRules(dir, sessionId);
+ // Discard if a newer request started
+ if (requestId !== thisRequest) return;
+ setRules(result.rules);
+ setSkippedCount(result.skippedCount);
+ setHasEvaluationState(result.hasEvaluationState);
+ setStatus('loaded');
+ } catch (err) {
+ // Discard if a newer request started
+ if (requestId !== thisRequest) return;
+ console.error('[opencode-rules] Failed to refresh rules:', err);
+ }
+ };
+
+ // Effect 1: Initial load on session/directory change
+ createEffect(() => {
+ const currentSessionId = props.sessionId;
+ const currentDir = resolveProjectDir();
+
+ // Check if session or directory changed
+ if (currentSessionId !== lastSessionId() || currentDir !== lastDir()) {
+ // Clear pending debounce from previous session
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ debounceTimer = null;
+ }
+ // Reset UI state on session/directory change
+ setExpandedIndex(null);
+ setProjectOpen(false);
+ setGlobalOpen(false);
+ void loadRulesInitial();
+ }
+ });
+
+ // Effect 2: Refresh on event-driven updates (refreshCounter changes)
+ createEffect(() => {
+ const counter = refreshCounter();
+ if (counter > 0) {
+ void loadRulesRefresh();
+ }
+ });
+
+ // Subscribe to OpenCode events with debounce
+ const triggerRefresh = (event: {
+ type: string;
+ properties: Record;
+ }): void => {
+ // Filter events to current sessionId before debouncing
+ // OpenCode SDK events nest sessionID inside properties: { type, properties: { sessionID, ... } }
+ const eventSessionID = event.properties.sessionID;
+ if (
+ typeof eventSessionID === 'string' &&
+ eventSessionID !== props.sessionId
+ ) {
+ return;
+ }
+
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ debounceTimer = setTimeout(() => {
+ debounceTimer = null;
+ setRefreshCounter(c => c + 1);
+ }, 150);
+ };
+
+ const unsubMessageUpdated = props.api.event.on(
+ 'message.updated',
+ triggerRefresh
+ );
+ const unsubSessionStatus = props.api.event.on(
+ 'session.status',
+ triggerRefresh
+ );
+
+ onCleanup(() => {
+ if (debounceTimer !== null) {
+ clearTimeout(debounceTimer);
+ }
+ unsubMessageUpdated();
+ unsubSessionStatus();
+ });
+
+ const toggleExpand = (index: number): void => {
+ setExpandedIndex(prev => (prev === index ? null : index));
+ };
+
+ const projectRules = createMemo(() =>
+ rules().filter(r => r.source === 'project')
+ );
+ const globalRules = createMemo(() =>
+ rules().filter(r => r.source === 'global')
+ );
+
+ return (
+
+
+ Rules
+
+
+
+ Loading...
+
+
+ Failed to load rules
+
+
+
+ 0}
+ fallback={No rules found}
+ >
+ setProjectOpen(x => !x)}
+ expandedIndex={expandedIndex()}
+ globalOffset={0}
+ onExpandToggle={toggleExpand}
+ hasEvaluationState={hasEvaluationState()}
+ />
+ setGlobalOpen(x => !x)}
+ expandedIndex={expandedIndex()}
+ globalOffset={projectRules().length}
+ onExpandToggle={toggleExpand}
+ hasEvaluationState={hasEvaluationState()}
+ />
+
+ 0}>
+
+ {skippedCount()} rules skipped (unreadable)
+
+
+
+
+ );
+}
diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts
new file mode 100644
index 0000000..4e11948
--- /dev/null
+++ b/tui/types/opencode-plugin-tui.d.ts
@@ -0,0 +1,78 @@
+// tui/types/opencode-plugin-tui.d.ts
+//
+// Vendored type declarations for @opencode-ai/plugin/tui.
+// Allows tsc to compile TUI code without requiring the optional
+// peer dependency to be installed at compile time.
+//
+// Source: @opencode-ai/plugin v1.3.7 (packages/plugin/src/tui.ts)
+// If bumping @opencode-ai/plugin, re-verify these types match.
+
+declare module '@opencode-ai/plugin/tui' {
+ export interface TuiTheme {
+ [key: string]: unknown;
+ }
+
+ export interface TuiSlotMap {
+ sidebar_content: { session_id: string };
+ }
+
+ export interface TuiSlotContext {
+ theme: TuiTheme;
+ }
+
+ export type SlotRenderer = (
+ ctx: Readonly,
+ props: TuiSlotMap[K]
+ ) => JSX.Element;
+
+ export interface TuiSlotPlugin {
+ id?: never;
+ order?: number;
+ setup?: (ctx: Readonly, renderer: unknown) => void;
+ dispose?: () => void;
+ slots: {
+ [K in keyof TuiSlotMap]?: SlotRenderer;
+ };
+ }
+
+ export interface TuiSlots {
+ register: (plugin: TuiSlotPlugin) => string;
+ }
+
+ export interface TuiWorkspace {
+ current: () => string | undefined;
+ set: (id?: string) => void;
+ }
+
+ export interface TuiState {
+ readonly path: {
+ state: string;
+ config: string;
+ worktree: string;
+ directory: string;
+ };
+ workspace: {
+ get: (id: string) => { directory: string | null } | undefined;
+ };
+ }
+
+ export interface TuiEventBus {
+ on: (
+ type: string,
+ handler: (event: {
+ type: string;
+ properties: Record;
+ }) => void
+ ) => () => void;
+ }
+
+ export interface TuiPluginApi {
+ slots: TuiSlots;
+ workspace: TuiWorkspace;
+ state: TuiState;
+ kv: unknown;
+ event: TuiEventBus;
+ }
+
+ export type TuiPlugin = (api: TuiPluginApi) => Promise | void;
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index 776fc9b..02a948d 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -4,7 +4,7 @@ export default defineConfig({
test: {
globals: true,
environment: 'node',
- include: ['src/**/*.{test,spec}.{ts,tsx}'],
+ include: ['src/**/*.{test,spec}.{ts,tsx}', 'tui/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
coverage: {
provider: 'v8',