From 58dbc74358fb872edaa65e3d9eb6b12aa969004a Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 5 Mar 2026 11:39:23 -0500 Subject: [PATCH 01/57] refactor(test-fixtures): remove dead code and internalize unexported types - Remove unused writeRuleFile function and writeFileSync import - Convert TestDirs, MockPluginInput interfaces to internal - Remove CI_ENV_VARS export (only used internally) - Remove dead message part builders (textPart, readToolPart, globToolPart, mockMessage) - Remove unused type exports (TextPart, ToolInvocationPart, MessagePart, MockMessage) Raises desloppify objective score to 95.8%. --- src/test-fixtures.ts | 126 +++---------------------------------------- 1 file changed, 6 insertions(+), 120 deletions(-) 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 // ============================================================================ From cecae9b9336ae9245fae1bab1bda9dfd50763773 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 5 Mar 2026 13:45:50 -0500 Subject: [PATCH 02/57] fix(desloppify): restore wontfix resolutions and clean runtime artifacts - Restore state file from 2ac31d8 (0 open findings, 24 wontfix) - Merge subjective review scores from 288d6aa - Remove runtime artifacts (review sessions, packets, subagent logs) - Add .gitignore entries for desloppify runtime dirs - Final: open=0, strict=97.5%, overall=98.7% --- .gitignore | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 0fcc280f636483a8b1c5f51e9df96b5b94e92c4b Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 5 Mar 2026 14:07:18 -0500 Subject: [PATCH 03/57] fix: apply follow-up review fixes for removed helper, typo, and test typing --- README.md | 2 +- package.json | 2 +- src/debug.test.ts | 13 ++++++++++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7497b3e..7f494e3 100644 --- a/README.md +++ b/README.md @@ -573,7 +573,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/package.json b/package.json index a686c86..87402e6 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "files": [ "dist", "README.md", - "lICENSE" + "LICENSE" ], "exports": { ".": { 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(() => {}); From 82ed5c93895d0d84bec71ebbc757d4488f705347 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 25 Mar 2026 10:08:26 -0400 Subject: [PATCH 04/57] docs: Update readme --- README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7f494e3..6a34256 100644 --- a/README.md +++ b/README.md @@ -435,9 +435,12 @@ The following highlights the primary runtime modules: - **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 +- **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 - **utils.ts** - Thin facade re-exporting from decomposed modules ### Build and Test From d586e32b82065e1ce2c5124989bc16c5675aa641 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 25 Mar 2026 10:09:23 -0400 Subject: [PATCH 05/57] chore: bump verson --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 87402e6..debce26 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.4.0", + "version": "0.5.0", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/index.js", "types": "dist/index.d.ts", From 8d4d6b608765d2f38865c3a7ee7aa53738942a55 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 25 Mar 2026 10:10:03 -0400 Subject: [PATCH 06/57] build: Add project rules --- .opencode/rules/10-runtime-consistency.md | 13 +++++++++++++ .opencode/rules/11-readme-and-doc-sync.md | 17 +++++++++++++++++ .opencode/rules/12-hotspot-guardrails.md | 12 ++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 .opencode/rules/10-runtime-consistency.md create mode 100644 .opencode/rules/11-readme-and-doc-sync.md create mode 100644 .opencode/rules/12-hotspot-guardrails.md 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. From 9c435d594aff6d778c796275a4e1404450468155 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 25 Mar 2026 10:22:31 -0400 Subject: [PATCH 07/57] docs(readme): link to .opencode/rules/ as real-world examples --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 6a34256..f50e75b 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,8 @@ Both directories are scanned recursively, allowing you to organize rules into su ## 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`: From d434d4e57de1fd81d15b9ee3154ba75e7e6cc1bb Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 25 Mar 2026 11:22:52 -0400 Subject: [PATCH 08/57] refactor(skills): polish crafting-rules per writing-skills best practices Consolidate 3 field reference tables into 1 unified table with Category column. Compress keyword selection from ~30 to ~18 lines. Trim examples from 6 to 4. Add Overview section and Common Mistakes section. Reorder sections per writing-skills template. Fix CSO description to remove workflow summary. --- skills/crafting-rules/SKILL.md | 135 +++++++++++++++++++-------------- 1 file changed, 77 insertions(+), 58 deletions(-) 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. From 52166f36ddb033075c55a0c26d36e27634642c10 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 02:37:59 +0000 Subject: [PATCH 09/57] feat!: change default export to { id, server } object shape --- src/index.integration.test.ts | 36 ++++++++++--- src/index.runtime.test.ts | 98 ++++++++++++++++++++++++++--------- src/index.test.ts | 48 ++++++++++++----- src/index.ts | 3 +- 4 files changed, 140 insertions(+), 45 deletions(-) diff --git a/src/index.integration.test.ts b/src/index.integration.test.ts index 009f275..5ad5a1b 100644 --- a/src/index.integration.test.ts +++ b/src/index.integration.test.ts @@ -748,7 +748,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 +809,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 +874,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( @@ -940,7 +946,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] @@ -970,7 +979,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 +1019,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 +1054,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 +1098,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.runtime.test.ts b/src/index.runtime.test.ts index 8086dc4..889498d 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -179,9 +179,10 @@ describe('OpenCodeRulesPlugin', () => { } }); - 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 +192,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 +215,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 +235,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( @@ -255,7 +262,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 +288,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( @@ -300,7 +311,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 +337,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( @@ -395,7 +410,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 +439,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 +473,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 +501,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 +529,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 +562,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 +607,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 +659,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 +698,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] @@ -740,7 +780,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 +808,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 +836,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 +864,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 +892,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..cb7b8db 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -65,7 +65,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 +115,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 +165,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 +216,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 +253,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 +293,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 +352,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 +410,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 +451,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 +488,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 +529,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 +562,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, diff --git a/src/index.ts b/src/index.ts index 112550d..3debaf2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -62,5 +62,6 @@ const __testOnly = Object.freeze( }) ); -export default openCodeRulesPlugin; +const id = 'opencode-rules' as const; +export default { id, server: openCodeRulesPlugin }; export { __testOnly }; From bc78244df02fb10ac74202fb85d88b430a6eb25e Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 02:46:49 +0000 Subject: [PATCH 10/57] =?UTF-8?q?build:=20restructure=20for=20tui/=20suppo?= =?UTF-8?q?rt=20=E2=80=94=20rootDir,=20jsx,=20peer=20deps,=20satisfies=20P?= =?UTF-8?q?lugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.js | 55 ++++++++++++++++++++++++++++++++++++++++++++++++ package.json | 31 +++++++++++++++++++++------ src/index.ts | 5 +++-- tsconfig.json | 14 +++++++++--- vitest.config.ts | 2 +- 5 files changed, 94 insertions(+), 13 deletions(-) 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 debce26..23732c9 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { "name": "opencode-rules", - "version": "0.5.0", + "version": "0.6.0", "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", "scripts": { "build": "tsc", @@ -39,23 +39,40 @@ ], "exports": { ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "types": "./dist/src/index.d.ts", + "import": "./dist/src/index.js" + } + }, + "peerDependencies": { + "@opencode-ai/plugin": "^1.3.7", + "@opencode-ai/sdk": "^1.3.7", + "@opentui/solid": ">=0.1.92", + "@opentui/core": ">=0.1.92" + }, + "peerDependenciesMeta": { + "@opentui/solid": { + "optional": true + }, + "@opentui/core": { + "optional": true } }, "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.12", "typescript": "^5.9.3", "vitest": "^1.6.1" }, "dependencies": { - "@opencode-ai/plugin": "^1.1.34", - "@opencode-ai/sdk": "^1.1.34", "minimatch": "^9.0.5", "yaml": "^2.8.2" } diff --git a/src/index.ts b/src/index.ts index 3debaf2..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'; @@ -63,5 +63,6 @@ const __testOnly = Object.freeze( ); const id = 'opencode-rules' as const; -export default { id, server: openCodeRulesPlugin }; +const server = openCodeRulesPlugin satisfies Plugin; +export default { id, server }; export { __testOnly }; 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/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', From 8e2ff2403319c46f5ddb940ba525466bf83a7989 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 02:57:31 +0000 Subject: [PATCH 11/57] build(tui): add vendored type shim for @opencode-ai/plugin/tui --- tui/types/opencode-plugin-tui.d.ts | 62 ++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tui/types/opencode-plugin-tui.d.ts diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts new file mode 100644 index 0000000..50f0754 --- /dev/null +++ b/tui/types/opencode-plugin-tui.d.ts @@ -0,0 +1,62 @@ +// 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 }; + [key: string]: Record; + } + + 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 { + workspace: { + get: (id: string) => { directory: string | null } | undefined; + }; + } + + export interface TuiPluginApi { + slots: TuiSlots; + workspace: TuiWorkspace; + state: TuiState; + kv: unknown; + } + + export type TuiPlugin = (api: TuiPluginApi) => Promise | void; +} From 9e4b565a7a808d42947e1c09eebf48a87b54c716 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 03:12:06 +0000 Subject: [PATCH 12/57] feat(tui): add data layer for sidebar rule loading --- tui/data/rules.test.ts | 344 +++++++++++++++++++++++++++++++++++++++++ tui/data/rules.ts | 195 +++++++++++++++++++++++ 2 files changed, 539 insertions(+) create mode 100644 tui/data/rules.test.ts create mode 100644 tui/data/rules.ts diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts new file mode 100644 index 0000000..9078e27 --- /dev/null +++ b/tui/data/rules.test.ts @@ -0,0 +1,344 @@ +// 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 { + 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; + + beforeEach(() => { + testDir = mkdtempSync(path.join(os.tmpdir(), 'tui-rules-test-')); + savedXDG = process.env['XDG_CONFIG_HOME']; + clearRuleCache(); + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + if (savedXDG === undefined) { + delete process.env['XDG_CONFIG_HOME']; + } else { + process.env['XDG_CONFIG_HOME'] = savedXDG; + } + }); + + 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(); + }); +}); + +/** 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 ?? {}, + }; +} diff --git a/tui/data/rules.ts b/tui/data/rules.ts new file mode 100644 index 0000000..3b21c84 --- /dev/null +++ b/tui/data/rules.ts @@ -0,0 +1,195 @@ +// tui/data/rules.ts +import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery.js'; +import type { RuleMetadata } from '../../src/rule-metadata.js'; +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; +} + +export interface LoadSidebarRulesResult { + rules: SidebarRuleEntry[]; + skippedCount: number; +} + +/** + * Load all discovered rules formatted for sidebar display. + * Reuses discoverRuleFiles/getCachedRule from the server plugin. + * + * @param projectDir - Project directory or null (global rules only) + */ +export async function loadSidebarRules( + projectDir: string | null +): Promise { + // discoverRuleFiles accepts string | undefined, not null + const discovered = await discoverRuleFiles(projectDir ?? undefined); + 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'; + + entries.push({ + name: '', // placeholder — set in disambiguation pass + path: rule.relativePath, + source, + isConditional, + conditionSummary, + metadata: meta ?? {}, + }); + } + + disambiguateNames(entries); + + // Sort: project first, then global. Alpha by name, path as tiebreaker. + entries.sort((a, b) => { + if (a.source !== b.source) return a.source === 'project' ? -1 : 1; + const nameCompare = a.name.localeCompare(b.name); + if (nameCompare !== 0) return nameCompare; + return a.path.localeCompare(b.path); + }); + + return { rules: entries, skippedCount }; +} + +/** + * 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; + } + } +} From 6c74b2a8dd4e957539f04682d4ee3deead5ad006 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 03:23:23 +0000 Subject: [PATCH 13/57] feat(tui): add sidebar component, entry point, and finalize package exports --- .npmignore | 14 +++ package.json | 28 ++++- tui/index.tsx | 22 ++++ tui/slots/sidebar-content.tsx | 159 +++++++++++++++++++++++++++++ tui/types/opencode-plugin-tui.d.ts | 3 +- 5 files changed, 221 insertions(+), 5 deletions(-) create mode 100644 .npmignore create mode 100644 tui/index.tsx create mode 100644 tui/slots/sidebar-content.tsx 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/package.json b/package.json index 23732c9..f77555e 100644 --- a/package.json +++ b/package.json @@ -5,14 +5,18 @@ "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" }, @@ -34,6 +38,20 @@ }, "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" ], @@ -41,6 +59,10 @@ ".": { "types": "./dist/src/index.d.ts", "import": "./dist/src/index.js" + }, + "./tui": { + "types": "./dist/tui/index.d.ts", + "import": "./tui/index.tsx" } }, "peerDependencies": { diff --git a/tui/index.tsx b/tui/index.tsx new file mode 100644 index 0000000..55cd1c9 --- /dev/null +++ b/tui/index.tsx @@ -0,0 +1,22 @@ +// tui/index.tsx +/** @jsxImportSource @opentui/solid */ +import type { TuiPlugin } from '@opencode-ai/plugin/tui'; +import { SidebarContent } from './slots/sidebar-content.js'; + +const id = 'opencode-rules' as const; + +const tui: TuiPlugin = async api => { + api.slots.register({ + 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..af36aaf --- /dev/null +++ b/tui/slots/sidebar-content.tsx @@ -0,0 +1,159 @@ +// tui/slots/sidebar-content.tsx +/** @jsxImportSource @opentui/solid */ +import { createSignal, createEffect, Show, For, type JSX } from 'solid-js'; +import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui'; +import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules.js'; + +interface SidebarContentProps { + sessionId: string; + api: TuiPluginApi; + theme: TuiTheme; +} + +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 [expandedIndex, setExpandedIndex] = createSignal(null); + const [lastDir, setLastDir] = createSignal( + undefined + ); + + const resolveProjectDir = (): string | null => { + const workspaceId = props.api.workspace.current(); + if (!workspaceId) return null; + const workspace = props.api.state.workspace.get(workspaceId); + return workspace?.directory ?? null; + }; + + const loadRules = async (): Promise => { + const dir = resolveProjectDir(); + if (dir === lastDir()) return; + + setLastDir(dir); + setStatus('loading'); + setExpandedIndex(null); + + try { + const result = await loadSidebarRules(dir); + setRules(result.rules); + setSkippedCount(result.skippedCount); + setStatus('loaded'); + } catch (err) { + console.error('[opencode-rules] Failed to load rules:', err); + setStatus('error'); + } + }; + + // Load rules on mount and reload when the workspace changes. + // Track both sessionId (which changes on workspace switch) and + // the resolved directory. The loadRules guard (dir === lastDir()) + // prevents redundant reloads if sessionId changes but dir stays the same. + createEffect(() => { + void props.sessionId; + const currentDir = resolveProjectDir(); + void currentDir; + void loadRules(); + }); + + const toggleExpand = (index: number): void => { + setExpandedIndex(prev => (prev === index ? null : index)); + }; + + return ( + + + Loading rules... + + + + Failed to load rules + + + + 0} fallback={No rules found}> + + {rules().length} rules loaded + {skippedCount() > 0 ? ` (${skippedCount()} skipped)` : ''} + + + + {(rule, index) => ( + toggleExpand(index())} + > + + + [{rule.source === 'project' ? 'P' : 'G'}] + {' '} + {rule.name} — {rule.conditionSummary} + + + + + Path: {rule.path} + + Source: {rule.source === 'project' ? 'project' : 'global'} + + 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} + + + + + )} + + + + + ); +} diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts index 50f0754..da1e39f 100644 --- a/tui/types/opencode-plugin-tui.d.ts +++ b/tui/types/opencode-plugin-tui.d.ts @@ -14,14 +14,13 @@ declare module '@opencode-ai/plugin/tui' { export interface TuiSlotMap { sidebar_content: { session_id: string }; - [key: string]: Record; } export interface TuiSlotContext { theme: TuiTheme; } - export type SlotRenderer = ( + export type SlotRenderer = ( ctx: Readonly, props: TuiSlotMap[K] ) => JSX.Element; From d0705b5edec3f111e5a49c1fcf712bdc54af0951 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 03:34:38 +0000 Subject: [PATCH 14/57] docs: update README with tui/ project structure and sidebar feature --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index f50e75b..4dfa331 100644 --- a/README.md +++ b/README.md @@ -421,6 +421,15 @@ opencode-rules/ │ ├── 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 @@ -445,6 +454,20 @@ The following highlights the primary runtime modules: - **git-branch.ts** - Resolves current git branch for `branch` condition matching - **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 metadata. + +**Requirements:** `@opencode-ai/plugin` ^1.3.7 with TUI support. + +**What it shows:** + +- Rule name with `[P]` (project) or `[G]` (global) prefix +- 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 +- Automatic reload on workspace change + ### Build and Test ```bash From 0363a81855aab7f3bd24daf6dea72f1d25107e0b Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 13:29:46 +0000 Subject: [PATCH 15/57] fix(tui): use content prop on text elements to avoid opentui #438 --- tui/slots/sidebar-content.tsx | 109 ++++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 44 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index af36aaf..132f255 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -65,19 +65,21 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { return ( - Loading rules... + - Failed to load rules + - 0} fallback={No rules found}> - - {rules().length} rules loaded - {skippedCount() > 0 ? ` (${skippedCount()} skipped)` : ''} - + 0} + fallback={} + > + 0 ? ` (${skippedCount()} skipped)` : ''}`} + /> {(rule, index) => ( @@ -85,67 +87,86 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { flexDirection="column" onMouseDown={() => toggleExpand(index())} > - - - [{rule.source === 'project' ? 'P' : 'G'}] - {' '} - {rule.name} — {rule.conditionSummary} - + + + + - Path: {rule.path} - - Source: {rule.source === 'project' ? 'project' : 'global'} - + + 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} + From 61c6bae8a1c053813ebcdfbf38a4ac01ea073649 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 14:28:57 +0000 Subject: [PATCH 16/57] fix(tui): add state.path to vendored TuiState types --- tui/types/opencode-plugin-tui.d.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts index da1e39f..e01b471 100644 --- a/tui/types/opencode-plugin-tui.d.ts +++ b/tui/types/opencode-plugin-tui.d.ts @@ -45,6 +45,12 @@ declare module '@opencode-ai/plugin/tui' { } export interface TuiState { + readonly path: { + state: string; + config: string; + worktree: string; + directory: string; + }; workspace: { get: (id: string) => { directory: string | null } | undefined; }; From 24f6b62c7b600ad4901070d546aebc7189731ecc Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 14:35:29 +0000 Subject: [PATCH 17/57] fix(tui): use api.state.path.directory for project dir resolution --- tui/slots/sidebar-content.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 132f255..2f49def 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -22,10 +22,7 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { ); const resolveProjectDir = (): string | null => { - const workspaceId = props.api.workspace.current(); - if (!workspaceId) return null; - const workspace = props.api.state.workspace.get(workspaceId); - return workspace?.directory ?? null; + return props.api.state.path.directory ?? null; }; const loadRules = async (): Promise => { From fe3f5f168572827bd0ae1a16ad7b752b16dda010 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 14:44:09 +0000 Subject: [PATCH 18/57] feat(tui): restyle sidebar as bordered card with project/global sections --- tui/slots/sidebar-content.tsx | 284 +++++++++++++++++++++++----------- 1 file changed, 193 insertions(+), 91 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 2f49def..a2247e6 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -1,6 +1,13 @@ // tui/slots/sidebar-content.tsx /** @jsxImportSource @opentui/solid */ -import { createSignal, createEffect, Show, For, type JSX } from 'solid-js'; +import { + createSignal, + createEffect, + createMemo, + Show, + For, + type JSX, +} from 'solid-js'; import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui'; import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules.js'; @@ -10,166 +17,261 @@ interface SidebarContentProps { theme: TuiTheme; } -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 [expandedIndex, setExpandedIndex] = createSignal(null); - const [lastDir, setLastDir] = createSignal( - undefined - ); - - const resolveProjectDir = (): string | null => { - return props.api.state.path.directory ?? null; - }; +const SINGLE_BORDER = { type: 'single' } as any; - const loadRules = async (): Promise => { - const dir = resolveProjectDir(); - if (dir === lastDir()) return; +type PaletteColor = import('@opentui/core').RGBA | string; - setLastDir(dir); - setStatus('loading'); - setExpandedIndex(null); +interface Palette { + panel: PaletteColor; + surface: PaletteColor; + text: PaletteColor; + muted: PaletteColor; + accent: PaletteColor; +} - try { - const result = await loadSidebarRules(dir); - setRules(result.rules); - setSkippedCount(result.skippedCount); - setStatus('loaded'); - } catch (err) { - console.error('[opencode-rules] Failed to load rules:', err); - setStatus('error'); - } +function getPalette(theme: TuiTheme): Palette { + const raw = theme.current as unknown as Record; + const get = (name: string, fallback: string): PaletteColor => { + const v = raw[name]; + if (typeof v === 'string') return v; + if (v && typeof v === 'object') return v as import('@opentui/core').RGBA; + return fallback; }; - - // Load rules on mount and reload when the workspace changes. - // Track both sessionId (which changes on workspace switch) and - // the resolved directory. The loadRules guard (dir === lastDir()) - // prevents redundant reloads if sessionId changes but dir stays the same. - createEffect(() => { - void props.sessionId; - const currentDir = resolveProjectDir(); - void currentDir; - void loadRules(); - }); - - const toggleExpand = (index: number): void => { - setExpandedIndex(prev => (prev === index ? null : index)); + return { + panel: get('backgroundPanel', '#111111'), + surface: get('background', '#171717'), + text: get('text', '#f0f0f0'), + muted: get('textMuted', '#a5a5a5'), + accent: get('primary', '#5f87ff'), }; +} - return ( - - - - - - - - - - - 0} - fallback={} - > - 0 ? ` (${skippedCount()} skipped)` : ''}`} - /> +interface RuleSectionProps { + label: string; + rules: SidebarRuleEntry[]; + palette: Palette; + expandedIndex: number | null; + globalOffset: number; + onToggle: (globalIndex: number) => void; +} - - {(rule, index) => ( +function RuleSection(props: RuleSectionProps): JSX.Element { + return ( + 0}> + + + {`${props.rules.length} ${props.label}`} + + + {(rule, localIndex) => { + const globalIndex = () => props.globalOffset + localIndex(); + return ( toggleExpand(index())} + onMouseDown={() => props.onToggle(globalIndex())} > - - - - - - - - + + + + 0}> 0}> 0}> 0}> 0}> 0}> 0}> 0}> 0}> - )} - + ); + }} + + + + ); +} + +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 [expandedIndex, setExpandedIndex] = createSignal(null); + const [lastDir, setLastDir] = createSignal( + undefined + ); + + const resolveProjectDir = (): string | null => { + return props.api.state.path.directory ?? null; + }; + + const loadRules = async (): Promise => { + const dir = resolveProjectDir(); + if (dir === lastDir()) return; + + setLastDir(dir); + setStatus('loading'); + setExpandedIndex(null); + + try { + const result = await loadSidebarRules(dir); + setRules(result.rules); + setSkippedCount(result.skippedCount); + setStatus('loaded'); + } catch (err) { + console.error('[opencode-rules] Failed to load rules:', err); + setStatus('error'); + } + }; + + // Load rules on mount and reload when the workspace changes. + // Track both sessionId (which changes on workspace switch) and + // the resolved directory. The loadRules guard (dir === lastDir()) + // prevents redundant reloads if sessionId changes but dir stays the same. + createEffect(() => { + void props.sessionId; + const currentDir = resolveProjectDir(); + void currentDir; + void loadRules(); + }); + + const toggleExpand = (index: number): void => { + setExpandedIndex(prev => (prev === index ? null : index)); + }; + + const palette = () => getPalette(props.theme); + const projectRules = createMemo(() => + rules().filter(r => r.source === 'project') + ); + const globalRules = createMemo(() => + rules().filter(r => r.source === 'global') + ); + + return ( + + {/* Header badge */} + + + + oc-rules + + + + + {/* Loading / Error states */} + + + + + + + + {/* Rule sections */} + + 0} + fallback={} + > + + + + 0}> + + {`${skippedCount()} rules skipped (unreadable)`} + From 628b667f532e72549e026c21caca9a4adf6371ab Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 14:57:27 +0000 Subject: [PATCH 19/57] feat(tui): set sidebar order to 90 to position below LSP --- tui/index.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/tui/index.tsx b/tui/index.tsx index 55cd1c9..a49f425 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -7,6 +7,7 @@ const id = 'opencode-rules' as const; const tui: TuiPlugin = async api => { api.slots.register({ + order: 90, slots: { sidebar_content: (ctx, props) => ( Date: Tue, 31 Mar 2026 15:13:20 +0000 Subject: [PATCH 20/57] fix(tui): Show between LSP and Modified files --- tui/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tui/index.tsx b/tui/index.tsx index a49f425..11a9818 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -7,7 +7,7 @@ const id = 'opencode-rules' as const; const tui: TuiPlugin = async api => { api.slots.register({ - order: 90, + order: 350, slots: { sidebar_content: (ctx, props) => ( Date: Tue, 31 Mar 2026 15:28:28 +0000 Subject: [PATCH 21/57] refactor(tui): flatten sidebar to match native opencode section style Remove card styling (background, border, padding) and palette abstraction. Add collapsible Project/Global sections with collapse-by-default behavior, bold 'Rules' header, and arrow toggle indicators matching MCP/LSP sections. --- tui/slots/sidebar-content.tsx | 286 +++++++++++++++------------------- 1 file changed, 125 insertions(+), 161 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index a2247e6..0edec72 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -17,139 +17,119 @@ interface SidebarContentProps { theme: TuiTheme; } -const SINGLE_BORDER = { type: 'single' } as any; +type ThemeColor = string | import('@opentui/core').RGBA; -type PaletteColor = import('@opentui/core').RGBA | string; - -interface Palette { - panel: PaletteColor; - surface: PaletteColor; - text: PaletteColor; - muted: PaletteColor; - accent: PaletteColor; -} - -function getPalette(theme: TuiTheme): Palette { - const raw = theme.current as unknown as Record; - const get = (name: string, fallback: string): PaletteColor => { - const v = raw[name]; - if (typeof v === 'string') return v; - if (v && typeof v === 'object') return v as import('@opentui/core').RGBA; - return fallback; - }; - return { - panel: get('backgroundPanel', '#111111'), - surface: get('background', '#171717'), - text: get('text', '#f0f0f0'), - muted: get('textMuted', '#a5a5a5'), - accent: get('primary', '#5f87ff'), - }; +interface ThemeColors { + text: ThemeColor; + textMuted: ThemeColor; + [key: string]: unknown; } interface RuleSectionProps { - label: string; + title: string; rules: SidebarRuleEntry[]; - palette: Palette; + theme: ThemeColors; + open: boolean; + onToggle: () => void; expandedIndex: number | null; globalOffset: number; - onToggle: (globalIndex: number) => void; + onExpandToggle: (globalIndex: number) => void; } function RuleSection(props: RuleSectionProps): JSX.Element { return ( 0}> - - - {`${props.rules.length} ${props.label}`} - - - {(rule, localIndex) => { - const globalIndex = () => props.globalOffset + localIndex(); - return ( - props.onToggle(globalIndex())} - > - - - - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - 0}> - - - - - - - - + + props.onToggle()}> + {props.open ? '▼' : '▶'} + + {props.title} + + + {' '} + ({props.rules.length}) + + + + + + + {(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} + + + + + + ); + }} + + ); @@ -165,6 +145,10 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { const [lastDir, setLastDir] = createSignal( undefined ); + const [projectOpen, setProjectOpen] = createSignal(false); + const [globalOpen, setGlobalOpen] = createSignal(false); + + const theme = (): ThemeColors => props.theme.current as ThemeColors; const resolveProjectDir = (): string | null => { return props.api.state.path.directory ?? null; @@ -177,6 +161,8 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { setLastDir(dir); setStatus('loading'); setExpandedIndex(null); + setProjectOpen(false); + setGlobalOpen(false); try { const result = await loadSidebarRules(dir); @@ -189,10 +175,6 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { } }; - // Load rules on mount and reload when the workspace changes. - // Track both sessionId (which changes on workspace switch) and - // the resolved directory. The loadRules guard (dir === lastDir()) - // prevents redundant reloads if sessionId changes but dir stays the same. createEffect(() => { void props.sessionId; const currentDir = resolveProjectDir(); @@ -204,7 +186,6 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { setExpandedIndex(prev => (prev === index ? null : index)); }; - const palette = () => getPalette(props.theme); const projectRules = createMemo(() => rules().filter(r => r.source === 'project') ); @@ -213,64 +194,47 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { ); return ( - - {/* Header badge */} - - - - oc-rules - - - + + + Rules + - {/* Loading / Error states */} - + Loading... - + Failed to load rules - {/* Rule sections */} 0} - fallback={} + fallback={No rules found} > setProjectOpen(x => !x)} expandedIndex={expandedIndex()} globalOffset={0} - onToggle={toggleExpand} + onExpandToggle={toggleExpand} /> setGlobalOpen(x => !x)} expandedIndex={expandedIndex()} globalOffset={projectRules().length} - onToggle={toggleExpand} + onExpandToggle={toggleExpand} /> 0}> - - {`${skippedCount()} rules skipped (unreadable)`} + + {skippedCount()} rules skipped (unreadable) From 8d8ba821b46aefe24fb41d65b50d1ec65a020efe Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:02:42 +0000 Subject: [PATCH 22/57] feat: add active rules state persistence module Filesystem-based IPC for TUI sidebar to know which rules are active. Provides atomic writes with per-session serialization and fire-and-forget API. --- src/active-rules-state.test.ts | 192 +++++++++++++++++++++++++++++++++ src/active-rules-state.ts | 125 +++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100644 src/active-rules-state.test.ts create mode 100644 src/active-rules-state.ts diff --git a/src/active-rules-state.test.ts b/src/active-rules-state.test.ts new file mode 100644 index 0000000..db22190 --- /dev/null +++ b/src/active-rules-state.test.ts @@ -0,0 +1,192 @@ +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')); + }); + }); + + 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('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..f9f8c4d --- /dev/null +++ b/src/active-rules-state.ts @@ -0,0 +1,125 @@ +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.js'; + +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; + +/** @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 { + return path.join(resolveStateDir(), `${sessionId}.json`); +} + +export function writeActiveRulesState( + sessionId: string, + matchedPaths: string[] +): void { + 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 { + const filePath = getStateFilePath(sessionId); + + try { + const content = await fs.readFile(filePath, 'utf-8'); + const parsed: unknown = JSON.parse(content); + + // Basic validation + if ( + typeof parsed === 'object' && + parsed !== null && + 'sessionId' in parsed && + 'matchedRulePaths' in parsed && + 'evaluatedAt' in parsed + ) { + return parsed as ActiveRulesState; + } + + debugLog(`Invalid active rules state format for session ${sessionId}`); + return null; + } catch (error) { + debugLog( + `Failed to read active rules state for session ${sessionId}: ${error}` + ); + return null; + } +} From 34c46ce9ac42fa0b2ac0c0c62e25e39f586d8017 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:09:19 +0000 Subject: [PATCH 23/57] fix: add sessionId validation and strict type guards - Validate sessionId against /^[A-Za-z0-9_-]+$/ to prevent path traversal - Add proper type guard for ActiveRulesState with full type checks - Reject invalid sessionIds early with debug logging --- src/active-rules-state.test.ts | 70 ++++++++++++++++++++++++++++++++++ src/active-rules-state.ts | 63 ++++++++++++++++++++++++------ 2 files changed, 122 insertions(+), 11 deletions(-) diff --git a/src/active-rules-state.test.ts b/src/active-rules-state.test.ts index db22190..86de4fe 100644 --- a/src/active-rules-state.test.ts +++ b/src/active-rules-state.test.ts @@ -60,6 +60,18 @@ describe('active-rules-state', () => { 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', () => { @@ -106,6 +118,64 @@ describe('active-rules-state', () => { 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']; diff --git a/src/active-rules-state.ts b/src/active-rules-state.ts index f9f8c4d..fd22bfd 100644 --- a/src/active-rules-state.ts +++ b/src/active-rules-state.ts @@ -18,6 +18,13 @@ 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; @@ -31,6 +38,9 @@ export function resolveStateDir(): string { } export function getStateFilePath(sessionId: string): string { + if (!isValidSessionId(sessionId)) { + throw new Error(`Invalid sessionId: ${sessionId}`); + } return path.join(resolveStateDir(), `${sessionId}.json`); } @@ -38,6 +48,11 @@ export function writeActiveRulesState( sessionId: string, matchedPaths: string[] ): void { + if (!isValidSessionId(sessionId)) { + debugLog(`Invalid sessionId rejected: ${sessionId}`); + return; + } + const state: ActiveRulesState = { sessionId, matchedRulePaths: matchedPaths, @@ -97,25 +112,23 @@ async function doAtomicWrite( 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); - // Basic validation - if ( - typeof parsed === 'object' && - parsed !== null && - 'sessionId' in parsed && - 'matchedRulePaths' in parsed && - 'evaluatedAt' in parsed - ) { - return parsed as ActiveRulesState; + if (!isValidActiveRulesState(parsed)) { + debugLog(`Invalid active rules state format for session ${sessionId}`); + return null; } - 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}` @@ -123,3 +136,31 @@ export async function readActiveRulesState( 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; +} From bb86c86df3c696201c29bce6e69c7fe8a5c33def Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:18:10 +0000 Subject: [PATCH 24/57] feat: add FilterResult return type with matchedPaths tracking - Change readAndFormatRules() return from string to FilterResult object - Add matchedPaths array tracking which rule files were included - Update runtime.ts to call writeActiveRulesState before early return - Re-export FilterResult from utils.ts - Update all test call sites to destructure new return type - Add tests verifying matchedPaths behavior --- src/index.integration.test.ts | 254 ++++++++++++++++++++++++---------- src/index.test.ts | 18 +-- src/rule-filter.ts | 26 +++- src/runtime.ts | 7 +- src/utils.ts | 1 + 5 files changed, 216 insertions(+), 90 deletions(-) diff --git a/src/index.integration.test.ts b/src/index.integration.test.ts index 5ad5a1b..8ffb146 100644 --- a/src/index.integration.test.ts +++ b/src/index.integration.test.ts @@ -42,18 +42,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 +64,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 +75,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 +90,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 +111,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 +132,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 +153,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 +176,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 +198,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 +220,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 +240,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 +262,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 +289,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 +317,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 +345,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 +366,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 +379,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 +389,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,7 +404,7 @@ All dimensions must match.` clearRuleCache(); const result = await readAndFormatRules(rules); - expect(result).toContain('Test Content'); + expect(result.formattedRules).toContain('Test Content'); }); }); }); @@ -473,8 +477,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 +499,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 +531,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 +539,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 +564,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 +572,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 +602,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 +611,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 +635,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 +666,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 +698,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,9 +716,109 @@ Only for special files.` '# Bare Rule\nShould always be included.' ); - const formatted = await readAndFormatRules(toRules([unconditionalPath])); + const { formattedRules } = await readAndFormatRules( + toRules([unconditionalPath]) + ); + + 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(formatted).toContain('Should always be included'); + expect(matchedPaths).toHaveLength(1); + expect(matchedPaths).toContain(unconditionalPath); }); }); }); diff --git a/src/index.test.ts b/src/index.test.ts index cb7b8db..fdf804f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -617,13 +617,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 () => { @@ -640,11 +640,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 () => { @@ -661,13 +661,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 () => { @@ -684,13 +684,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/rule-filter.ts b/src/rule-filter.ts index c65e051..706a70f 100644 --- a/src/rule-filter.ts +++ b/src/rule-filter.ts @@ -59,6 +59,14 @@ export function toolsMatchAvailable( return requiredTools.some(tool => availableSet.has(tool)); } +/** + * Result of reading and formatting rules + */ +export interface FilterResult { + formattedRules: string; + matchedPaths: string[]; +} + /** * Runtime filter context for conditional rule matching */ @@ -93,12 +101,13 @@ export interface RuleFilterContext { export async function readAndFormatRules( files: DiscoveredRule[], context: RuleFilterContext = {} -): Promise { +): Promise { if (files.length === 0) { - return ''; + return { formattedRules: '', matchedPaths: [] }; } const ruleContents: string[] = []; + const matchedPaths: string[] = []; const availableToolSet = context.availableToolIDs && context.availableToolIDs.length > 0 ? new Set(context.availableToolIDs) @@ -244,14 +253,17 @@ export async function readAndFormatRules( // Use cached stripped content for output // Use relativePath for unique headings instead of just filename ruleContents.push(`## ${relativePath}\n\n${strippedContent}`); + matchedPaths.push(filePath); } if (ruleContents.length === 0) { - return ''; + return { formattedRules: '', matchedPaths: [] }; } - return ( - `# OpenCode Rules\n\nPlease follow the following rules:\n\n` + - ruleContents.join('\n\n---\n\n') - ); + return { + formattedRules: + `# OpenCode Rules\n\nPlease follow the following rules:\n\n` + + ruleContents.join('\n\n---\n\n'), + matchedPaths, + }; } diff --git a/src/runtime.ts b/src/runtime.ts index 33bddd9..f198fde 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -21,6 +21,7 @@ import { type ChatMessageInput, type ChatMessageOutput, } from './runtime-chat.js'; +import { writeActiveRulesState } from './active-rules-state.js'; interface MessagesTransformOutput { messages: MessageWithInfo[]; @@ -217,11 +218,15 @@ export class OpenCodeRulesRuntime { this.debugLog ); - const formattedRules = await readAndFormatRules( + const { formattedRules, matchedPaths } = await readAndFormatRules( this.ruleFiles, filterContext ); + if (sessionID) { + writeActiveRulesState(sessionID, matchedPaths); + } + if (!formattedRules) { this.debugLog('No applicable rules for current context'); return output ?? {}; diff --git a/src/utils.ts b/src/utils.ts index dae9e4a..d92062a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -25,6 +25,7 @@ export { toolsMatchAvailable, readAndFormatRules, type RuleFilterContext, + type FilterResult, } from './rule-filter.js'; // Re-export from message-paths From b708d475c7c0197ec4658498b486aeb8a4135a5d Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:26:31 +0000 Subject: [PATCH 25/57] test: add integration tests for writeActiveRulesState runtime side effect - Test that matching rules populate matchedRulePaths in state file - Test that no-match case writes empty matchedPaths array - Test that missing sessionID skips state write - Use _setStateDirForTesting for test isolation --- src/index.runtime.test.ts | 141 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 889498d..2756a77 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -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', () => { @@ -725,6 +729,143 @@ describe('SessionState', () => { }); }); +describe('Active rules state persistence', () => { + let savedEnvXDG: string | undefined; + let stateDir: string; + + beforeEach(() => { + setupTestDirs(); + savedEnvXDG = process.env.XDG_CONFIG_HOME; + 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; + } + }); + + 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)); + + // No state file should exist for undefined session + const state = await readActiveRulesState('undefined'); + expect(state).toBeNull(); + }); +}); + describe('utils runtime exports', () => { it('exports only expected functions at runtime', () => { const exportedKeys = Object.keys(utilsModule).sort(); From 22d95d413bdddd8f37dbb49d5cbea8a86eb28ea7 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:27:53 +0000 Subject: [PATCH 26/57] fix(test): verify no state files created when sessionID is missing Use readdirSync to assert state directory has no .json files instead of only checking readActiveRulesState for a specific key. --- src/index.runtime.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 2756a77..5726933 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, @@ -860,9 +860,10 @@ Conditional rule for gpt-5 only.` // Wait briefly await new Promise(resolve => setTimeout(resolve, 50)); - // No state file should exist for undefined session - const state = await readActiveRulesState('undefined'); - expect(state).toBeNull(); + // 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); }); }); From ae08da3151e12736408fb26eb3299e6725276474 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:31:51 +0000 Subject: [PATCH 27/57] feat(tui): add active state tracking to sidebar rules - Add isActive (boolean | null) to SidebarRuleEntry for UI state display - Add hasEvaluationState to LoadSidebarRulesResult - Update loadSidebarRules() with optional sessionId parameter to read state - Extend TuiPluginApi type shim with TuiEventBus interface and event property - Add comprehensive tests for isActive behavior with/without state file --- tui/data/rules.test.ts | 158 +++++++++++++++++++++++++++++ tui/data/rules.ts | 35 ++++++- tui/types/opencode-plugin-tui.d.ts | 5 + 3 files changed, 196 insertions(+), 2 deletions(-) diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts index 9078e27..f2dc669 100644 --- a/tui/data/rules.test.ts +++ b/tui/data/rules.test.ts @@ -4,6 +4,10 @@ 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, @@ -329,6 +333,159 @@ describe('loadSidebarRules', () => { }); }); +// ────────────────────────────────────────────── +// loadSidebarRules isActive behavior +// ────────────────────────────────────────────── + +describe('loadSidebarRules isActive behavior', () => { + let testDir: string; + let stateDir: string; + let savedXDG: 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']; + 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; + } + }); + + 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 } @@ -340,5 +497,6 @@ function makeEntry( 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 index 3b21c84..4c21b44 100644 --- a/tui/data/rules.ts +++ b/tui/data/rules.ts @@ -1,6 +1,7 @@ // tui/data/rules.ts import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery.js'; import type { RuleMetadata } from '../../src/rule-metadata.js'; +import { readActiveRulesState } from '../../src/active-rules-state.js'; import path from 'path'; /** Represents a rule as displayed in the sidebar */ @@ -17,11 +18,20 @@ export interface SidebarRuleEntry { 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; } /** @@ -29,12 +39,22 @@ export interface LoadSidebarRulesResult { * 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 + 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; @@ -54,6 +74,16 @@ export async function loadSidebarRules( ? 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, @@ -61,6 +91,7 @@ export async function loadSidebarRules( isConditional, conditionSummary, metadata: meta ?? {}, + isActive, }); } @@ -74,7 +105,7 @@ export async function loadSidebarRules( return a.path.localeCompare(b.path); }); - return { rules: entries, skippedCount }; + return { rules: entries, skippedCount, hasEvaluationState }; } /** diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts index e01b471..7752989 100644 --- a/tui/types/opencode-plugin-tui.d.ts +++ b/tui/types/opencode-plugin-tui.d.ts @@ -56,11 +56,16 @@ declare module '@opencode-ai/plugin/tui' { }; } + export interface TuiEventBus { + on: (type: string, handler: (...args: unknown[]) => void) => () => void; + } + export interface TuiPluginApi { slots: TuiSlots; workspace: TuiWorkspace; state: TuiState; kv: unknown; + event: TuiEventBus; } export type TuiPlugin = (api: TuiPluginApi) => Promise | void; From 6338a3268eb5d73de46764a954b67d5ac24b1ef7 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:41:42 +0000 Subject: [PATCH 28/57] feat(tui): add event-driven refresh and active state UI - Subscribe to message.updated and session.status events via TuiEventBus - Debounce 150ms before re-reading rules on events - Verify sessionId/projectDir match before committing async results - Separate initial-load effect (resets UI state) from refresh effect - Add hasEvaluationState prop to RuleSection for header count format - Green bullet for isActive === true, grey for false/null - Header count shows (active/total) when hasEvaluationState, else (total) - Unsubscribe from events on cleanup via onCleanup --- tui/slots/sidebar-content.tsx | 117 ++++++++++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 11 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 0edec72..dea513a 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -4,6 +4,7 @@ import { createSignal, createEffect, createMemo, + onCleanup, Show, For, type JSX, @@ -34,9 +35,27 @@ interface RuleSectionProps { expandedIndex: number | null; globalOffset: number; onExpandToggle: (globalIndex: number) => void; + hasEvaluationState: boolean; } +const BULLET_GREEN: ThemeColor = 'green'; + 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 ? BULLET_GREEN : props.theme.textMuted; + }; + return ( 0}> @@ -47,7 +66,7 @@ function RuleSection(props: RuleSectionProps): JSX.Element { {' '} - ({props.rules.length}) + {headerCount()} @@ -62,7 +81,7 @@ function RuleSection(props: RuleSectionProps): JSX.Element { onMouseDown={() => props.onExpandToggle(globalIndex())} > - + {rule.name} @@ -141,12 +160,17 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { '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; @@ -154,20 +178,24 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { return props.api.state.path.directory ?? null; }; - const loadRules = async (): Promise => { + // Initial load: triggered by session/directory change, resets all UI state + const loadRulesInitial = async (): Promise => { const dir = resolveProjectDir(); - if (dir === lastDir()) return; + const sessionId = props.sessionId; setLastDir(dir); + setLastSessionId(sessionId); setStatus('loading'); - setExpandedIndex(null); - setProjectOpen(false); - setGlobalOpen(false); try { - const result = await loadSidebarRules(dir); + const result = await loadSidebarRules(dir, sessionId); + // Verify context hasn't changed during async load + if (resolveProjectDir() !== dir || props.sessionId !== sessionId) { + return; // Discard stale result + } setRules(result.rules); setSkippedCount(result.skippedCount); + setHasEvaluationState(result.hasEvaluationState); setStatus('loaded'); } catch (err) { console.error('[opencode-rules] Failed to load rules:', err); @@ -175,11 +203,76 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { } }; + // Refresh load: triggered by events, only updates rule data (no UI state reset) + const loadRulesRefresh = async (): Promise => { + const dir = resolveProjectDir(); + const sessionId = props.sessionId; + + try { + const result = await loadSidebarRules(dir, sessionId); + // Verify context hasn't changed during async load + if (resolveProjectDir() !== dir || props.sessionId !== sessionId) { + return; // Discard stale result + } + setRules(result.rules); + setSkippedCount(result.skippedCount); + setHasEvaluationState(result.hasEvaluationState); + } catch (err) { + console.error('[opencode-rules] Failed to refresh rules:', err); + } + }; + + // Effect 1: Initial load on session/directory change createEffect(() => { - void props.sessionId; + const currentSessionId = props.sessionId; const currentDir = resolveProjectDir(); - void currentDir; - void loadRules(); + + // Check if session or directory changed + if (currentSessionId !== lastSessionId() || currentDir !== lastDir()) { + // 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 + let debounceTimer: ReturnType | null = null; + + const triggerRefresh = (): void => { + 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 => { @@ -220,6 +313,7 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { expandedIndex={expandedIndex()} globalOffset={0} onExpandToggle={toggleExpand} + hasEvaluationState={hasEvaluationState()} /> 0}> From 01e59a3ee556242c76ba8ebdb4c5b5222a4e2eb1 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:45:11 +0000 Subject: [PATCH 29/57] fix(tui): filter events by sessionId before debouncing Check event payload for sessionId property at receipt time. If the event has a sessionId that doesn't match the current session, skip it immediately rather than waiting for the post-load staleness check. --- tui/slots/sidebar-content.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index dea513a..2fbea1c 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -248,7 +248,19 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { // Subscribe to OpenCode events with debounce let debounceTimer: ReturnType | null = null; - const triggerRefresh = (): void => { + const triggerRefresh = (...args: unknown[]): void => { + // Filter events to current sessionId before debouncing + const event = args[0]; + if ( + event !== null && + typeof event === 'object' && + 'sessionId' in event && + typeof (event as Record).sessionId === 'string' && + (event as Record).sessionId !== props.sessionId + ) { + return; + } + if (debounceTimer !== null) { clearTimeout(debounceTimer); } From 05d8cdb173ab2709ffa6ad3543e4bbf93d17f6e3 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:50:33 +0000 Subject: [PATCH 30/57] fix(tui): prevent stale async results and improve refresh robustness - Add monotonic requestId counter to detect out-of-order async completions - Call setStatus('loaded') in loadRulesRefresh to recover from error state - Clear debounceTimer on session/directory change to prevent stale events --- tui/slots/sidebar-content.tsx | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 2fbea1c..e1f45ce 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -178,8 +178,14 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { 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; @@ -189,15 +195,15 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { try { const result = await loadSidebarRules(dir, sessionId); - // Verify context hasn't changed during async load - if (resolveProjectDir() !== dir || props.sessionId !== sessionId) { - return; // Discard stale result - } + // 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'); } @@ -205,19 +211,21 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { // Refresh load: triggered by events, only updates rule data (no UI state reset) const loadRulesRefresh = async (): Promise => { + const thisRequest = ++requestId; const dir = resolveProjectDir(); const sessionId = props.sessionId; try { const result = await loadSidebarRules(dir, sessionId); - // Verify context hasn't changed during async load - if (resolveProjectDir() !== dir || props.sessionId !== sessionId) { - return; // Discard stale result - } + // 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); } }; @@ -229,6 +237,11 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { // 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); @@ -246,8 +259,6 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { }); // Subscribe to OpenCode events with debounce - let debounceTimer: ReturnType | null = null; - const triggerRefresh = (...args: unknown[]): void => { // Filter events to current sessionId before debouncing const event = args[0]; From 62eafb08723ce8d359f45f669f7502fd12e6ca96 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:53:10 +0000 Subject: [PATCH 31/57] fix(tui): skip refresh during initial load and use proper green color - Guard loadRulesRefresh() to skip when status is 'loading', preventing refresh from invalidating an in-flight initial load - Update bullet green color from 'green' to '#02a25a' for better visibility --- tui/slots/sidebar-content.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index e1f45ce..adc1481 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -38,7 +38,7 @@ interface RuleSectionProps { hasEvaluationState: boolean; } -const BULLET_GREEN: ThemeColor = 'green'; +const BULLET_GREEN: ThemeColor = '#02a25a'; function RuleSection(props: RuleSectionProps): JSX.Element { const activeCount = createMemo( @@ -211,6 +211,9 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { // 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; From 13645eb283f2fbf36dad52f10691179b1ccdb4b2 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 17:55:57 +0000 Subject: [PATCH 32/57] fix(tui): use theme.success for active bullet color instead of hardcoded hex --- tui/slots/sidebar-content.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index adc1481..2d312d0 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -23,6 +23,7 @@ type ThemeColor = string | import('@opentui/core').RGBA; interface ThemeColors { text: ThemeColor; textMuted: ThemeColor; + success: ThemeColor; [key: string]: unknown; } @@ -38,8 +39,6 @@ interface RuleSectionProps { hasEvaluationState: boolean; } -const BULLET_GREEN: ThemeColor = '#02a25a'; - function RuleSection(props: RuleSectionProps): JSX.Element { const activeCount = createMemo( () => props.rules.filter(r => r.isActive === true).length @@ -53,7 +52,7 @@ function RuleSection(props: RuleSectionProps): JSX.Element { }); const bulletColor = (rule: SidebarRuleEntry): ThemeColor => { - return rule.isActive === true ? BULLET_GREEN : props.theme.textMuted; + return rule.isActive === true ? props.theme.success : props.theme.textMuted; }; return ( From 9342ba7320d2e16521cfe24255d37f04f0555c54 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 18:04:55 +0000 Subject: [PATCH 33/57] fix(tui): use correct event shape for sessionID filtering OpenCode SDK events nest sessionID inside properties object with capital D (event.properties.sessionID), not event.sessionId. Also improves vendored TuiEventBus type to match real event structure. --- tui/slots/sidebar-content.tsx | 15 ++++++++------- tui/types/opencode-plugin-tui.d.ts | 8 +++++++- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index 2d312d0..ee6ff54 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -261,15 +261,16 @@ export function SidebarContent(props: SidebarContentProps): JSX.Element { }); // Subscribe to OpenCode events with debounce - const triggerRefresh = (...args: unknown[]): void => { + const triggerRefresh = (event: { + type: string; + properties: Record; + }): void => { // Filter events to current sessionId before debouncing - const event = args[0]; + // OpenCode SDK events nest sessionID inside properties: { type, properties: { sessionID, ... } } + const eventSessionID = event.properties.sessionID; if ( - event !== null && - typeof event === 'object' && - 'sessionId' in event && - typeof (event as Record).sessionId === 'string' && - (event as Record).sessionId !== props.sessionId + typeof eventSessionID === 'string' && + eventSessionID !== props.sessionId ) { return; } diff --git a/tui/types/opencode-plugin-tui.d.ts b/tui/types/opencode-plugin-tui.d.ts index 7752989..4e11948 100644 --- a/tui/types/opencode-plugin-tui.d.ts +++ b/tui/types/opencode-plugin-tui.d.ts @@ -57,7 +57,13 @@ declare module '@opencode-ai/plugin/tui' { } export interface TuiEventBus { - on: (type: string, handler: (...args: unknown[]) => void) => () => void; + on: ( + type: string, + handler: (event: { + type: string; + properties: Record; + }) => void + ) => () => void; } export interface TuiPluginApi { From 3bc131986f7c30093d85bcdf35bf029591bdadf1 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 18:10:34 +0000 Subject: [PATCH 34/57] feat(tui): sort active rules to top of sidebar categories --- tui/data/rules.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tui/data/rules.ts b/tui/data/rules.ts index 4c21b44..01ee163 100644 --- a/tui/data/rules.ts +++ b/tui/data/rules.ts @@ -97,9 +97,13 @@ export async function loadSidebarRules( disambiguateNames(entries); - // Sort: project first, then global. Alpha by name, path as tiebreaker. + // 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); From 8b4dd06edf61d6a7424db763966ce9cfa91504ce Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 18:33:23 +0000 Subject: [PATCH 35/57] docs(readme): update TUI sidebar docs with active state, event refresh, and state persistence - Add TUI sidebar to Features list - Add active-rules-state.ts to Project Structure and Key Module Responsibilities - Update rule-filter.ts description to mention FilterResult return type - Replace inaccurate [P]/[G] prefix claim with collapsible section groups - Document active/inactive indicators, sort-to-top, and event-driven refresh - Add State Persistence step to How It Works flow --- README.md | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4dfa331..b476e40 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ 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 @@ -417,6 +418,7 @@ 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 @@ -445,28 +447,35 @@ 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) +- **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 metadata. +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:** -- Rule name with `[P]` (project) or `[G]` (global) prefix +- 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 -- Automatic reload on workspace change + +**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 @@ -566,7 +575,8 @@ These APIs may change in future OpenCode versions. Check OpenCode release notes 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 +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 ## Performance From acfa901661e312e20ac29b2ef3bf3eb524a365c0 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 18:41:26 +0000 Subject: [PATCH 36/57] docs(README): Reorganize --- README.md | 270 ++++++++++++++++++++++++++---------------------------- 1 file changed, 129 insertions(+), 141 deletions(-) diff --git a/README.md b/README.md index b476e40..b585871 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,9 @@ 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 @@ -70,6 +73,28 @@ 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 +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. **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 + +## 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 @@ -86,6 +111,110 @@ 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 +--- +``` + +### 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 + +**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. @@ -285,117 +414,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) @@ -549,14 +567,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: @@ -567,28 +577,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. **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 - -## 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: From cca1dc436ce22ee9e20b98f73495868b408e973b Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 18:46:25 +0000 Subject: [PATCH 37/57] docs(readme): add tui.json configuration to installation section --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index b585871..607e1f2 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,15 @@ 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: From 7d5d5207e4fe57d22dd7239d51714d01baefde3d Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 19:00:56 +0000 Subject: [PATCH 38/57] test(discovery): add failing tests for OPENCODE_CONFIG_DIR as global dir --- src/index.rules.test.ts | 45 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) 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', () => { From 4805ce08acd1d2379ce98d0a95106f842a22f4db Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 19:07:00 +0000 Subject: [PATCH 39/57] feat(discovery): add OPENCODE_CONFIG_DIR support in getGlobalRulesDir Add OPENCODE_CONFIG_DIR as highest-priority source for global rules, followed by XDG_CONFIG_HOME, then ~/.config fallback. --- src/rule-discovery.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/rule-discovery.ts b/src/rule-discovery.ts index 3148ce5..36154fa 100644 --- a/src/rule-discovery.ts +++ b/src/rule-discovery.ts @@ -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. From e03d45005bce262096a3c5c03e7da035966a587c Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 19:09:12 +0000 Subject: [PATCH 40/57] docs: document OPENCODE_CONFIG_DIR environment variable --- README.md | 2 +- docs/rules.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 607e1f2..4652238 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ That's it! The rule will now be automatically injected into all AI agent prompts 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. diff --git a/docs/rules.md b/docs/rules.md index 3ecdd6f..d1f01fd 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 `~/.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. From 3b3a8288b48921fcf431e1072252043e9020227a Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 19:19:30 +0000 Subject: [PATCH 41/57] fix(tests): isolate OPENCODE_CONFIG_DIR across all test suites --- docs/rules.md | 2 +- src/index.integration.test.ts | 32 ++++++++++++++++++++++++++++++++ src/index.runtime.test.ts | 32 ++++++++++++++++++++++++++++++++ src/index.test.ts | 16 ++++++++++++++++ tui/data/rules.test.ts | 16 ++++++++++++++++ 5 files changed, 97 insertions(+), 1 deletion(-) diff --git a/docs/rules.md b/docs/rules.md index d1f01fd..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:** `$OPENCODE_CONFIG_DIR/rules/` if set, otherwise `~/.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/src/index.integration.test.ts b/src/index.integration.test.ts index 8ffb146..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 () => { @@ -411,10 +419,13 @@ All dimensions must match.` 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(); }); @@ -425,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', () => { @@ -825,10 +841,13 @@ Excluded rule.` 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(); }); @@ -841,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 () => { @@ -1032,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(); }); @@ -1048,6 +1075,11 @@ 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 () => { diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 5726933..2034e04 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -166,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(() => { @@ -181,6 +184,11 @@ 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 plugin module with id and server', async () => { @@ -381,10 +389,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 () => { @@ -396,6 +407,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 () => { @@ -731,11 +747,14 @@ 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 }); @@ -752,6 +771,11 @@ describe('Active rules state persistence', () => { } 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 () => { @@ -892,11 +916,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 () => { @@ -909,6 +936,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 () => { diff --git a/src/index.test.ts b/src/index.test.ts index fdf804f..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 () => { @@ -588,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(); }); @@ -602,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 () => { diff --git a/tui/data/rules.test.ts b/tui/data/rules.test.ts index f2dc669..c274ff4 100644 --- a/tui/data/rules.test.ts +++ b/tui/data/rules.test.ts @@ -215,10 +215,13 @@ describe('disambiguateNames', () => { 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(); }); @@ -229,6 +232,11 @@ describe('loadSidebarRules', () => { } 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 () => { @@ -341,12 +349,15 @@ 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); }); @@ -359,6 +370,11 @@ describe('loadSidebarRules isActive behavior', () => { } 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 () => { From 7828e6742bc48067eaf1c2be94441816ad1c8e00 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 20:08:22 +0000 Subject: [PATCH 42/57] fix(deps): pin solid-js to 1.9.11 to resolve peer dependency conflict --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f77555e..4a7af17 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.0", + "version": "0.6.0-beta", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -90,7 +90,7 @@ "@typescript-eslint/parser": "^6.21.0", "eslint": "^8.57.1", "prettier": "^3.8.1", - "solid-js": "^1.9.12", + "solid-js": "1.9.11", "typescript": "^5.9.3", "vitest": "^1.6.1" }, From f99152f75d178dbdfe4994d658a5cc0053c13aac Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 16:15:13 -0400 Subject: [PATCH 43/57] docs(readme): add plugin CLI as recommended install option --- README.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/README.md b/README.md index 607e1f2..673f2da 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,13 @@ approach. ### Installation +```bash +opencode plugin opencode-rules@latest --global +``` + +
+Manual installation + Add the plugin to your opencode config: ```json @@ -60,6 +67,8 @@ To enable the TUI sidebar, add the same plugin entry to your TUI config: } ``` +
+ ### Create Your First Rule 1. Create the global rules directory: From 9c9b71e7bb6167a372cc960b3bdb9d111373d614 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 31 Mar 2026 16:17:25 -0400 Subject: [PATCH 44/57] chore: bump to 0.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 4a7af17..a0ce0cd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.0-beta", + "version": "0.6.0", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", From 282097846347f30f9d544a5bc137cb08efd939ed Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 2 Apr 2026 18:35:31 +0000 Subject: [PATCH 45/57] fix(deps): move opentui packages from peer to direct dependencies OpenCode's plugin loader now uses @npmcli/arborist for isolated installs, which does not resolve optional peer dependencies from the host. Moving @opentui/solid, @opentui/core, and solid-js to direct dependencies ensures the JSX runtime is available in the plugin's isolated node_modules directory. --- package.json | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index a0ce0cd..b5bd646 100644 --- a/package.json +++ b/package.json @@ -67,17 +67,7 @@ }, "peerDependencies": { "@opencode-ai/plugin": "^1.3.7", - "@opencode-ai/sdk": "^1.3.7", - "@opentui/solid": ">=0.1.92", - "@opentui/core": ">=0.1.92" - }, - "peerDependenciesMeta": { - "@opentui/solid": { - "optional": true - }, - "@opentui/core": { - "optional": true - } + "@opencode-ai/sdk": "^1.3.7" }, "devDependencies": { "@opencode-ai/plugin": "^1.3.9", @@ -95,7 +85,10 @@ "vitest": "^1.6.1" }, "dependencies": { + "@opentui/core": "^0.1.93", + "@opentui/solid": "^0.1.93", "minimatch": "^9.0.5", + "solid-js": "1.9.11", "yaml": "^2.8.2" } } From e296bff817422df8ff18fb3a4467ab4d25ad7e62 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 2 Apr 2026 18:35:42 +0000 Subject: [PATCH 46/57] chore: bump to 0.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b5bd646..0401505 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.0", + "version": "0.6.1", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", From da80149ef26f031fa93b95333a529bf684eb84fd Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 2 Apr 2026 18:56:43 +0000 Subject: [PATCH 47/57] fix: use dynamic import for yaml CJS interop Bun's module linker in OpenCode's TUI process treats the yaml CJS package as async. Static ESM imports, default imports, and createRequire all fail. Use await import() which is the only supported path for async CJS modules in Bun. --- src/rule-metadata.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rule-metadata.ts b/src/rule-metadata.ts index fd644da..b15f506 100644 --- a/src/rule-metadata.ts +++ b/src/rule-metadata.ts @@ -2,7 +2,7 @@ * Rule metadata parsing and frontmatter extraction */ -import { parse as parseYaml } from 'yaml'; +const { parse: parseYaml } = await import('yaml'); /** * Metadata extracted from .mdc file frontmatter From 9abdf69aa4be049c5038f849d6dcfd3396095c68 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 2 Apr 2026 18:57:00 +0000 Subject: [PATCH 48/57] chore: bump to 0.6.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 0401505..00d92ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.1", + "version": "0.6.2", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", From eaf7dc49e729ecfada1dd72fb13fbbdbc56ce975 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Tue, 7 Apr 2026 20:10:01 +0000 Subject: [PATCH 49/57] fix(tui): point tui export to compiled dist output The ./tui export referenced raw source (./tui/index.tsx) which caused Bun's module resolver to fail on .js import specifiers that only exist in the dist directory. --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 00d92ca..6411baf 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "./tui": { "types": "./dist/tui/index.d.ts", - "import": "./tui/index.tsx" + "import": "./dist/tui/index.js" } }, "peerDependencies": { From c2dd1bc8a7ad57716dba628127396060e0e71d21 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 8 Apr 2026 20:14:43 +0000 Subject: [PATCH 50/57] build(ci): add CI and release GitHub Actions workflows --- .github/workflows/ci.yml | 26 +++++++++++++++++++++++ .github/workflows/release.yml | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..50c2ea6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - 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..14db8c2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,39 @@ +name: Release + +on: + push: + tags: ['v*'] + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + + - 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: Publish to npm + run: bunx npm publish --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true From e0052ce932e33cb3e2a873649091cf0e39107942 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Wed, 8 Apr 2026 20:33:38 +0000 Subject: [PATCH 51/57] build(ci): harden workflows with SHA pinning, least-privilege permissions, and native bun publish --- .github/workflows/ci.yml | 9 +++++++-- .github/workflows/release.yml | 15 ++++++++++----- package.json | 2 +- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50c2ea6..3fc3bf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,18 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: check: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 - run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 14db8c2..a1e733e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,11 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: oven-sh/setup-bun@v2 + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + with: + bun-version: 1.3.11 - run: bun install --frozen-lockfile @@ -28,12 +30,15 @@ jobs: - name: Build run: bun run build + - name: Verify package contents + run: bun pm pack --dry-run + - name: Publish to npm - run: bunx npm publish --access public + run: bun publish --access public env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@26e8ad27a09a225049a7075d7ec1caa2df6ff332 # v2 with: generate_release_notes: true diff --git a/package.json b/package.json index 6411baf..ad19593 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "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", From 40445d966dbf28507b895f2aa6bdec17c43e0017 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Thu, 9 Apr 2026 22:34:41 +0000 Subject: [PATCH 52/57] chore: bump to 0.6.3 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad19593..fdda47f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.2", + "version": "0.6.3", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", From 193d2a5f32d886276d94c643dff17d0346571262 Mon Sep 17 00:00:00 2001 From: Fernando Visbal Date: Wed, 8 Apr 2026 17:45:21 -0500 Subject: [PATCH 53/57] fix: prevent infinite loop by tracking rule injection state - Add rulesInjected and lastInjectedAt to SessionState - Skip injection if rulesInjected is true - Reset rulesInjected when new user prompt arrives - Add test cases for the new state tracking - All 145 tests pass --- src/runtime-chat.ts | 1 + src/runtime.ts | 27 +++++++++++++++++++++++---- src/session-store.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ src/session-store.ts | 2 ++ 4 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src/runtime-chat.ts b/src/runtime-chat.ts index 7f12af7..372e6f7 100644 --- a/src/runtime-chat.ts +++ b/src/runtime-chat.ts @@ -51,6 +51,7 @@ export function handleChatMessage( sessionStore.upsert(sessionID, state => { if (userPrompt) { state.lastUserPrompt = userPrompt; + state.rulesInjected = false; } if (input.model?.modelID) { diff --git a/src/runtime.ts b/src/runtime.ts index f198fde..1262329 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -197,6 +197,13 @@ export class OpenCodeRulesRuntime { } } + if (sessionState?.rulesInjected) { + this.debugLog( + `Session ${sessionID} already has rules injected - skipping to prevent loop` + ); + return output ?? {}; + } + const contextPaths = sessionState ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b)) : []; @@ -235,17 +242,29 @@ export class OpenCodeRulesRuntime { this.debugLog('Injecting rules into system prompt'); if (!output) { + if (sessionID) { + this.sessionStore.upsert(sessionID, state => { + state.rulesInjected = true; + state.lastInjectedAt = this.now(); + }); + } 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(); + }); + } return output; } 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..d46fb31 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -8,6 +8,8 @@ export interface SessionState { seedCount?: number; lastModelID?: string; lastAgentType?: string; + rulesInjected?: boolean; + lastInjectedAt?: number; } interface SessionStoreOptions { From 11575324029822f70c88531f812dd067306eba61 Mon Sep 17 00:00:00 2001 From: ZanzyTHEbar Date: Mon, 13 Apr 2026 13:01:47 +0100 Subject: [PATCH 54/57] fix(tui): load sidebar from source entry --- package.json | 2 +- src/active-rules-state.ts | 2 +- src/rule-discovery.ts | 4 ++-- tui/data/rules.ts | 6 +++--- tui/index.tsx | 2 +- tui/slots/sidebar-content.tsx | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index fdda47f..19340de 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ }, "./tui": { "types": "./dist/tui/index.d.ts", - "import": "./dist/tui/index.js" + "import": "./tui/index.tsx" } }, "peerDependencies": { diff --git a/src/active-rules-state.ts b/src/active-rules-state.ts index fd22bfd..aaba3ec 100644 --- a/src/active-rules-state.ts +++ b/src/active-rules-state.ts @@ -2,7 +2,7 @@ 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.js'; +import { createDebugLog } from './debug'; const debugLog = createDebugLog(); diff --git a/src/rule-discovery.ts b/src/rule-discovery.ts index 36154fa..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(); diff --git a/tui/data/rules.ts b/tui/data/rules.ts index 01ee163..61cbd03 100644 --- a/tui/data/rules.ts +++ b/tui/data/rules.ts @@ -1,7 +1,7 @@ // tui/data/rules.ts -import { discoverRuleFiles, getCachedRule } from '../../src/rule-discovery.js'; -import type { RuleMetadata } from '../../src/rule-metadata.js'; -import { readActiveRulesState } from '../../src/active-rules-state.js'; +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 */ diff --git a/tui/index.tsx b/tui/index.tsx index 11a9818..5364ceb 100644 --- a/tui/index.tsx +++ b/tui/index.tsx @@ -1,7 +1,7 @@ // tui/index.tsx /** @jsxImportSource @opentui/solid */ import type { TuiPlugin } from '@opencode-ai/plugin/tui'; -import { SidebarContent } from './slots/sidebar-content.js'; +import { SidebarContent } from './slots/sidebar-content'; const id = 'opencode-rules' as const; diff --git a/tui/slots/sidebar-content.tsx b/tui/slots/sidebar-content.tsx index ee6ff54..cef1135 100644 --- a/tui/slots/sidebar-content.tsx +++ b/tui/slots/sidebar-content.tsx @@ -10,7 +10,7 @@ import { type JSX, } from 'solid-js'; import type { TuiPluginApi, TuiTheme } from '@opencode-ai/plugin/tui'; -import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules.js'; +import { loadSidebarRules, type SidebarRuleEntry } from '../data/rules'; interface SidebarContentProps { sessionId: string; From 3cf9cc5209efa637f8525baae3e4a96627d8c6dd Mon Sep 17 00:00:00 2001 From: DaOfficialWizard Date: Wed, 15 Apr 2026 10:23:32 +0100 Subject: [PATCH 55/57] docs: Update notes formatting in README.md Update NOTEs to github markdown syntax for proper rendering. --- README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 51eff4d..00742c3 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ 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 +> [!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 @@ -199,7 +200,8 @@ match: any - `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. +> [!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 From 744162ca7621a4ea6689ec2d144b9dd4c2fd1f29 Mon Sep 17 00:00:00 2001 From: Joe Maples Date: Sat, 25 Apr 2026 13:15:00 +0000 Subject: [PATCH 56/57] chore: bump to 0.6.4 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 19340de..e9046f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-rules", - "version": "0.6.3", + "version": "0.6.4", "description": "OpenCode plugin that discovers and injects markdown rules into system prompts", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", From e44ce262058ed14fb7e2ed42a5399bdac35ecbfd Mon Sep 17 00:00:00 2001 From: Antonio Orizondo Leyva Date: Wed, 13 May 2026 18:41:37 -0400 Subject: [PATCH 57/57] feat: add inject mode, repeat_every interval, and content-based dedup - Inject mode (`inject: system | user | both`) to control injection target - `repeat_every` metadata for configurable user-prompt injection intervals - Content-based dedup: skip rule contents already present in system prompt - Raw mode: zero-header injection (no preamble, no [Reinforcement Instructions]) - Remove chunking: single-message user injection with synthetic flag - Track individual rule contents in FilterResult for accurate dedup - Update tests for raw-mode system prompt injection - Update README with new metadata fields and behavior docs --- README.md | 20 +++ src/index.runtime.test.ts | 7 +- src/rule-filter.ts | 73 ++++++++-- src/rule-metadata.ts | 36 +++++ src/runtime-chat.ts | 3 +- src/runtime.ts | 276 ++++++++++++++++++++++++++++++-------- src/session-store.ts | 5 + src/utils.ts | 3 +- 8 files changed, 351 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 00742c3..f2a2be2 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,17 @@ That's it! The rule will now be automatically injected into all AI agent prompts 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 @@ -165,6 +172,10 @@ os: ci: false # Matching mode match: any +# Injection mode +inject: system +# Repeat interval (turns) for user-prompt injection +repeat_every: 1 --- ``` @@ -199,6 +210,15 @@ match: any - `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. diff --git a/src/index.runtime.test.ts b/src/index.runtime.test.ts index 2034e04..02ba707 100644 --- a/src/index.runtime.test.ts +++ b/src/index.runtime.test.ts @@ -265,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 () => { @@ -314,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 () => { diff --git a/src/rule-filter.ts b/src/rule-filter.ts index 706a70f..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(); @@ -65,6 +66,7 @@ export function toolsMatchAvailable( export interface FilterResult { formattedRules: string; matchedPaths: string[]; + individualContents: string[]; } /** @@ -94,20 +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 = {} + context: RuleFilterContext = {}, + injectMode?: InjectMode, + opts?: ReadAndFormatOptions ): Promise { if (files.length === 0) { - return { formattedRules: '', matchedPaths: [] }; + 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) @@ -250,20 +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 { formattedRules: '', matchedPaths: [] }; + return { formattedRules: '', matchedPaths: [], individualContents: [] }; } - return { - formattedRules: + 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'), + 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 { + formattedRules, matchedPaths, + individualContents, }; } diff --git a/src/rule-metadata.ts b/src/rule-metadata.ts index b15f506..1a931c7 100644 --- a/src/rule-metadata.ts +++ b/src/rule-metadata.ts @@ -7,6 +7,8 @@ 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 372e6f7..5bd216e 100644 --- a/src/runtime-chat.ts +++ b/src/runtime-chat.ts @@ -51,7 +51,8 @@ export function handleChatMessage( sessionStore.upsert(sessionID, state => { if (userPrompt) { state.lastUserPrompt = userPrompt; - state.rulesInjected = false; + 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 1262329..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, @@ -45,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; @@ -129,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)); + } + if (userPrompt && !state.lastUserPrompt) { + state.lastUserPrompt = userPrompt; + } + state.seededFromHistory = true; + state.seedCount = (state.seedCount ?? 0) + 1; + }); - this.sessionStore.upsert(sessionID, state => { - for (const p of contextPaths) { - state.contextPaths.add(normalizeContextPath(p, this.projectDirectory)); + 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; } @@ -174,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 @@ -197,45 +332,67 @@ export class OpenCodeRulesRuntime { } } - if (sessionState?.rulesInjected) { - this.debugLog( - `Session ${sessionID} already has rules injected - skipping to prevent loop` - ); - return output ?? {}; - } + const filterContext = await this.buildCurrentFilterContext(sessionID ?? ''); + const result = await readAndFormatRules( + this.ruleFiles, + filterContext, + 'system', + { raw: true } + ); - const contextPaths = sessionState - ? Array.from(sessionState.contextPaths).sort((a, b) => a.localeCompare(b)) - : []; - const userPrompt = sessionState?.lastUserPrompt; + let { formattedRules, matchedPaths, individualContents } = result; - const availableToolIDs = await this.queryAvailableToolIDs(); + if (!formattedRules) { + this.debugLog('No applicable rules for current context'); + if (sessionID) { + writeActiveRulesState(sessionID, []); + } + return output ?? {}; + } - const filterContextOpts: BuildFilterContextOptions = { - contextFilePaths: contextPaths, - userPrompt, - availableToolIDs, - modelID: sessionState?.lastModelID, - agentType: sessionState?.lastAgentType, - }; + // 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 filterContext: RuleFilterContext = await buildFilterContext( - filterContextOpts, - this.projectDirectory, - this.debugLog - ); + if (newContents.length === 0) { + this.debugLog('All rules already present in system prompt - skipping injection'); + if (sessionID) { + writeActiveRulesState(sessionID, []); + } + return output ?? {}; + } - const { formattedRules, matchedPaths } = await readAndFormatRules( - this.ruleFiles, - filterContext - ); + // Rebuild formattedRules from non-duplicate contents + formattedRules = newContents.join('\n\n'); + matchedPaths = newPaths; if (sessionID) { writeActiveRulesState(sessionID, matchedPaths); } - if (!formattedRules) { - this.debugLog('No applicable rules for current context'); + const rulesHash = hashString(formattedRules); + + if (sessionState?.lastInjectedRulesHash === rulesHash) { + this.debugLog( + `Session ${sessionID} rules unchanged - skipping injection to preserve KV-cache` + ); return output ?? {}; } @@ -246,6 +403,7 @@ export class OpenCodeRulesRuntime { this.sessionStore.upsert(sessionID, state => { state.rulesInjected = true; state.lastInjectedAt = this.now(); + state.lastInjectedRulesHash = rulesHash; }); } return { system: formattedRules }; @@ -263,6 +421,7 @@ export class OpenCodeRulesRuntime { this.sessionStore.upsert(sessionID, state => { state.rulesInjected = true; state.lastInjectedAt = this.now(); + state.lastInjectedRulesHash = rulesHash; }); } @@ -340,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.ts b/src/session-store.ts index d46fb31..1f374d2 100644 --- a/src/session-store.ts +++ b/src/session-store.ts @@ -10,6 +10,9 @@ export interface SessionState { lastAgentType?: string; rulesInjected?: boolean; lastInjectedAt?: number; + turnCount: number; + lastUserInjectTurn: number; + lastInjectedRulesHash?: string; } interface SessionStoreOptions { @@ -120,6 +123,8 @@ export class SessionStore { lastUpdated: ++this.tick, seededFromHistory: false, seedCount: 0, + turnCount: 0, + lastUserInjectTurn: 0, }; } } diff --git a/src/utils.ts b/src/utils.ts index d92062a..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 { @@ -26,6 +26,7 @@ export { readAndFormatRules, type RuleFilterContext, type FilterResult, + type ReadAndFormatOptions, } from './rule-filter.js'; // Re-export from message-paths