From 7471fcfe7398da8eb2d60ea60878b28d0317fe8a Mon Sep 17 00:00:00 2001 From: Marius Wichtner Date: Mon, 26 Jan 2026 17:50:58 +0100 Subject: [PATCH] feat(kilocode): add rules migration (Phase 2) Migrate Kilocode rules from .kilocoderules and .kilocode/rules/ to Opencode's instructions config array. Features: - Discover rules from .kilocoderules, .kilocode/rules/*.md, and mode-specific variants - Support global rules from ~/.kilocode/rules/ - Read-only migration (never modifies project files) - Combines with existing opencode config (never overwrites) - Kilocode-only (no .roorules or .clinerules migration) Files: - rules-migrator.ts: Core migration logic - config.ts: Integration point for direct CLI usage - config-injector.ts: Integration for VSCode extension path - rules-migrator.test.ts: 13 unit tests - config-injector.test.ts: 4 new integration tests - rules-migration.md: Documentation --- packages/opencode/src/config/config.ts | 21 ++ .../opencode/src/kilocode/config-injector.ts | 19 ++ .../src/kilocode/docs/rules-migration.md | 130 ++++++++++++ packages/opencode/src/kilocode/index.ts | 1 + .../opencode/src/kilocode/rules-migrator.ts | 136 ++++++++++++ .../test/kilocode/config-injector.test.ts | 86 ++++++++ .../test/kilocode/rules-migrator.test.ts | 195 ++++++++++++++++++ 7 files changed, 588 insertions(+) create mode 100644 packages/opencode/src/kilocode/docs/rules-migration.md create mode 100644 packages/opencode/src/kilocode/rules-migrator.ts create mode 100644 packages/opencode/test/kilocode/rules-migrator.test.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ba26ff4f276..0beb3a6298d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -29,6 +29,7 @@ import { Bus } from "@/bus" import { GlobalBus } from "@/bus/global" import { Event } from "../server/event" import { ModesMigrator } from "../kilocode/modes-migrator" // kilocode_change +import { RulesMigrator } from "../kilocode/rules-migrator" // kilocode_change export namespace Config { const log = Log.create({ service: "config" }) @@ -116,6 +117,26 @@ export namespace Config { } // kilocode_change end + // kilocode_change start - Load Kilocode rules + try { + const kilocodeRules = await RulesMigrator.migrate({ + projectDir: Instance.directory, + }) + if (kilocodeRules.instructions.length > 0) { + result = mergeConfigConcatArrays(result, { instructions: kilocodeRules.instructions }) + log.debug("loaded kilocode rules", { + count: kilocodeRules.instructions.length, + files: kilocodeRules.instructions, + }) + } + for (const warning of kilocodeRules.warnings) { + log.debug("kilocode rules warning", { warning }) + } + } catch (err) { + log.warn("failed to load kilocode rules", { error: err }) + } + // kilocode_change end + result.agent = result.agent || {} result.mode = result.mode || {} result.plugin = result.plugin || [] diff --git a/packages/opencode/src/kilocode/config-injector.ts b/packages/opencode/src/kilocode/config-injector.ts index fa8ee799039..eab4dace6df 100644 --- a/packages/opencode/src/kilocode/config-injector.ts +++ b/packages/opencode/src/kilocode/config-injector.ts @@ -1,5 +1,6 @@ import { Config } from "../config/config" import { ModesMigrator } from "./modes-migrator" +import { RulesMigrator } from "./rules-migrator" // kilocode_change export namespace KilocodeConfigInjector { export interface InjectionResult { @@ -12,6 +13,8 @@ export namespace KilocodeConfigInjector { globalSettingsDir?: string /** Skip reading from global paths (VSCode storage, home dir). Used for testing. */ skipGlobalPaths?: boolean + /** Include rules migration. Defaults to true. */ + includeRules?: boolean }): Promise { const warnings: string[] = [] @@ -30,6 +33,22 @@ export namespace KilocodeConfigInjector { config.agent = modesMigration.agents } + // kilocode_change start - Rules migration + if (options.includeRules !== false) { + const rulesMigration = await RulesMigrator.migrate({ + projectDir: options.projectDir, + includeGlobal: !options.skipGlobalPaths, + includeModeSpecific: true, + }) + + warnings.push(...rulesMigration.warnings) + + if (rulesMigration.instructions.length > 0) { + config.instructions = rulesMigration.instructions + } + } + // kilocode_change end + return { configJson: JSON.stringify(config), warnings, diff --git a/packages/opencode/src/kilocode/docs/rules-migration.md b/packages/opencode/src/kilocode/docs/rules-migration.md new file mode 100644 index 00000000000..a0076a9aea5 --- /dev/null +++ b/packages/opencode/src/kilocode/docs/rules-migration.md @@ -0,0 +1,130 @@ +# Kilocode Rules Migration + +This document explains how Kilocode rules are automatically migrated to Opencode's `instructions` config array. + +## Overview + +Kilocode stores rules in various file locations. When Opencode starts, it reads these files and injects their paths into the `instructions` config array, which Opencode then loads as part of the system prompt. + +## Key Guarantees + +### 1. Read-Only Migration +The migration **never modifies project files**. We only: +- Read existing rule files from disk +- Inject file paths into the config's `instructions` array +- Never write to the project or modify any files + +### 2. Combines with Existing Config (Never Overwrites) +If you have existing opencode config with `instructions`, the Kilocode rules are **combined**, not replaced: + +```typescript +// Example: User has opencode.json with: +{ "instructions": ["AGENTS.md", "custom-rules.md"] } + +// Kilocode rules add: +{ "instructions": [".kilocoderules", ".kilocode/rules/coding.md"] } + +// Result (combined, deduplicated): +{ "instructions": ["AGENTS.md", "custom-rules.md", ".kilocoderules", ".kilocode/rules/coding.md"] } +``` + +### 3. Restart to Pick Up Changes +If you change your Kilocode configuration (e.g., edit `.kilocoderules`), simply restart kilo-cli to pick up the new config. No manual migration or conversion needed. + +## Source Locations + +The migrator reads rules from these locations: + +### Project Rules + +| Location | Description | +|----------|-------------| +| `.kilocoderules` | Legacy single-file rules in project root | +| `.kilocode/rules/*.md` | Directory-based rules (multiple markdown files) | +| `.kilocoderules-{mode}` | Mode-specific legacy rules (e.g., `.kilocoderules-code`) | +| `.kilocode/rules-{mode}/*.md` | Mode-specific rule directories | + +### Global Rules + +| Location | Description | +|----------|-------------| +| `~/.kilocode/rules/*.md` | Global rules directory | + +## File Mapping + +| Kilocode Location | Opencode Equivalent | +|-------------------|---------------------| +| `.kilocoderules` | `instructions: [".kilocoderules"]` | +| `.kilocoderules-{mode}` | `instructions: [".kilocoderules-{mode}"]` | +| `.kilocode/rules/*.md` | `instructions: [".kilocode/rules/file.md", ...]` | +| `.kilocode/rules-{mode}/*.md` | `instructions: [".kilocode/rules-{mode}/file.md", ...]` | +| `~/.kilocode/rules/*.md` | `instructions: ["~/.kilocode/rules/file.md", ...]` | + +## AGENTS.md Compatibility + +`AGENTS.md` is loaded **natively** by Opencode - no migration needed. Opencode automatically loads: +- `AGENTS.md` in project root +- `CLAUDE.md` in project root +- `~/.opencode/AGENTS.md` (global) + +## Not Migrated + +The following are **not** migrated: +- `.roorules` - Roo-specific rules +- `.clinerules` - Cline-specific rules + +Only Kilocode-specific files (`.kilocoderules`, `.kilocode/rules/`) are migrated. + +## Mode-Specific Rules + +Mode-specific rules (e.g., `.kilocoderules-code`, `.kilocode/rules-architect/`) are included by default. All mode-specific rules are loaded regardless of the current mode. + +## Warnings + +The migrator generates warnings for: +- **Legacy files**: When `.kilocoderules` is found, a warning suggests migrating to `.kilocode/rules/` directory structure + +## Example + +### Before (Kilocode) + +``` +project/ +├── .kilocoderules # Legacy rules +├── .kilocoderules-code # Code-mode specific +└── .kilocode/ + └── rules/ + ├── coding.md # Coding standards + └── testing.md # Testing guidelines +``` + +### After (Opencode Config) + +```json +{ + "instructions": [ + "/path/to/project/.kilocode/rules/coding.md", + "/path/to/project/.kilocode/rules/testing.md", + "/path/to/project/.kilocoderules", + "/path/to/project/.kilocoderules-code" + ] +} +``` + +## Troubleshooting + +### Rules not appearing + +1. Check the file exists at the expected location +2. Ensure markdown files have `.md` extension +3. Restart kilo-cli to pick up changes + +### Duplicate rules + +The `mergeConfigConcatArrays` function automatically deduplicates the `instructions` array using `Array.from(new Set([...]))`. + +## Related Files + +- [`rules-migrator.ts`](../rules-migrator.ts) - Core migration logic +- [`config-injector.ts`](../config-injector.ts) - Config building and injection +- [`modes-migration.md`](./modes-migration.md) - Modes migration documentation diff --git a/packages/opencode/src/kilocode/index.ts b/packages/opencode/src/kilocode/index.ts index 6995757e810..e0c8b562afd 100644 --- a/packages/opencode/src/kilocode/index.ts +++ b/packages/opencode/src/kilocode/index.ts @@ -1,2 +1,3 @@ export { ModesMigrator } from "./modes-migrator" +export { RulesMigrator } from "./rules-migrator" // kilocode_change export { KilocodeConfigInjector } from "./config-injector" diff --git a/packages/opencode/src/kilocode/rules-migrator.ts b/packages/opencode/src/kilocode/rules-migrator.ts new file mode 100644 index 00000000000..ed5543e0497 --- /dev/null +++ b/packages/opencode/src/kilocode/rules-migrator.ts @@ -0,0 +1,136 @@ +// kilocode_change - new file + +import * as fs from "fs/promises" +import * as path from "path" +import os from "os" + +export namespace RulesMigrator { + // Only support .kilocoderules (no migration for .roorules or .clinerules) + const LEGACY_RULE_FILE = ".kilocoderules" + + // Directory-based rules + const KILOCODE_RULES_DIR = ".kilocode/rules" + const GLOBAL_RULES_DIR = path.join(os.homedir(), ".kilocode", "rules") + + // Known modes for mode-specific rule discovery + const KNOWN_MODES = ["code", "architect", "ask", "debug", "orchestrator"] + + export interface RuleFile { + path: string + source: "global" | "project" | "legacy" + mode?: string // e.g., "code", "architect" - undefined means applies to all modes + } + + export interface MigrationResult { + instructions: string[] + warnings: string[] + } + + async function exists(filepath: string): Promise { + return Bun.file(filepath).exists() + } + + async function isDirectory(filepath: string): Promise { + try { + const stat = await fs.stat(filepath) + return stat.isDirectory() + } catch { + return false + } + } + + async function findMarkdownFiles(dir: string): Promise { + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => path.join(dir, e.name)) + } catch { + return [] + } + } + + export async function discoverRules(projectDir: string): Promise { + const rules: RuleFile[] = [] + + // 1. Global rules directory (~/.kilocode/rules/*.md) + if (await isDirectory(GLOBAL_RULES_DIR)) { + const files = await findMarkdownFiles(GLOBAL_RULES_DIR) + for (const file of files) { + rules.push({ path: file, source: "global" }) + } + } + + // 2. Project .kilocode/rules/ directory + const projectRulesDir = path.join(projectDir, KILOCODE_RULES_DIR) + if (await isDirectory(projectRulesDir)) { + const files = await findMarkdownFiles(projectRulesDir) + for (const file of files) { + rules.push({ path: file, source: "project" }) + } + } + + // 3. Legacy .kilocoderules file (only kilocode, not roo/cline) + const legacyFile = path.join(projectDir, LEGACY_RULE_FILE) + if (await exists(legacyFile)) { + rules.push({ path: legacyFile, source: "legacy" }) + } + + // 4. Mode-specific rules + for (const mode of KNOWN_MODES) { + // Mode-specific directory (.kilocode/rules-{mode}/*.md) + const modeDir = path.join(projectDir, `.kilocode/rules-${mode}`) + if (await isDirectory(modeDir)) { + const files = await findMarkdownFiles(modeDir) + for (const file of files) { + rules.push({ path: file, source: "project", mode }) + } + } + + // Legacy mode-specific file (.kilocoderules-{mode}) + const legacyModeFile = path.join(projectDir, `.kilocoderules-${mode}`) + if (await exists(legacyModeFile)) { + rules.push({ path: legacyModeFile, source: "legacy", mode }) + } + } + + return rules + } + + export async function migrate(options: { + projectDir: string + includeGlobal?: boolean + includeModeSpecific?: boolean + }): Promise { + const warnings: string[] = [] + const instructions: string[] = [] + const includeGlobal = options.includeGlobal ?? true + const includeModeSpecific = options.includeModeSpecific ?? true + + const rules = await discoverRules(options.projectDir) + + for (const rule of rules) { + // Skip global if not requested + if (rule.source === "global" && !includeGlobal) { + continue + } + + // Skip mode-specific if not requested + if (rule.mode && !includeModeSpecific) { + warnings.push(`Mode-specific rule '${path.basename(rule.path)}' skipped (mode: ${rule.mode})`) + continue + } + + // Add to instructions array + instructions.push(rule.path) + + // Warn about legacy files + if (rule.source === "legacy") { + warnings.push( + `Legacy rule file '${path.basename(rule.path)}' found. ` + + `Consider migrating to .kilocode/rules/ directory.`, + ) + } + } + + return { instructions, warnings } + } +} diff --git a/packages/opencode/test/kilocode/config-injector.test.ts b/packages/opencode/test/kilocode/config-injector.test.ts index 45d98be06c8..510f098ffd0 100644 --- a/packages/opencode/test/kilocode/config-injector.test.ts +++ b/packages/opencode/test/kilocode/config-injector.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test" import { KilocodeConfigInjector } from "../../src/kilocode/config-injector" import { tmpdir } from "../fixture/fixture" import path from "path" +import fs from "fs/promises" describe("KilocodeConfigInjector", () => { describe("buildConfig", () => { @@ -59,6 +60,91 @@ describe("KilocodeConfigInjector", () => { expect(result.warnings[0]).toContain("code") expect(result.warnings[0]).toContain("skipped") }) + + // kilocode_change start - Rules migration tests + test("includes rules in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.instructions).toBeDefined() + expect(config.instructions).toHaveLength(1) + expect(config.instructions[0]).toContain("main.md") + }) + + test("skips rules when includeRules is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + includeRules: false, + }) + const config = JSON.parse(result.configJson) + + expect(config.instructions).toBeUndefined() + }) + + test("combines modes and rules in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Add custom mode + await Bun.write( + path.join(dir, ".kilocodemodes"), + `customModes: + - slug: translate + name: Translate + roleDefinition: You are a translator + groups: + - read`, + ) + // Add rules + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + const config = JSON.parse(result.configJson) + + expect(config.agent).toBeDefined() + expect(config.agent.translate).toBeDefined() + expect(config.instructions).toBeDefined() + expect(config.instructions).toHaveLength(1) + }) + + test("adds warnings for legacy rule files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + }, + }) + + const result = await KilocodeConfigInjector.buildConfig({ + projectDir: tmp.path, + skipGlobalPaths: true, + }) + + expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true) + }) + // kilocode_change end }) describe("getEnvVars", () => { diff --git a/packages/opencode/test/kilocode/rules-migrator.test.ts b/packages/opencode/test/kilocode/rules-migrator.test.ts new file mode 100644 index 00000000000..31c929a38db --- /dev/null +++ b/packages/opencode/test/kilocode/rules-migrator.test.ts @@ -0,0 +1,195 @@ +import { test, expect, describe } from "bun:test" +import { RulesMigrator } from "../../src/kilocode/rules-migrator" +import { tmpdir } from "../fixture/fixture" +import path from "path" +import fs from "fs/promises" + +describe("RulesMigrator", () => { + describe("discoverRules", () => { + test("discovers legacy .kilocoderules file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Project rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("legacy") + expect(rules[0].path).toContain(".kilocoderules") + expect(rules[0].mode).toBeUndefined() + }) + + test("discovers .kilocode/rules/ directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "coding.md"), "# Coding rules") + await Bun.write(path.join(dir, ".kilocode", "rules", "testing.md"), "# Testing rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(2) + expect(rules.every((r) => r.source === "project")).toBe(true) + expect(rules.every((r) => r.mode === undefined)).toBe(true) + }) + + test("discovers mode-specific directory rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules-code"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules-code", "style.md"), "# Code style") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("project") + expect(rules[0].mode).toBe("code") + }) + + test("discovers mode-specific legacy file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].source).toBe("legacy") + expect(rules[0].mode).toBe("architect") + }) + + test("ignores non-markdown files in rules directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "rules.md"), "# Rules") + await Bun.write(path.join(dir, ".kilocode", "rules", "notes.txt"), "Notes") + await Bun.write(path.join(dir, ".kilocode", "rules", "config.json"), "{}") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(1) + expect(rules[0].path).toContain("rules.md") + }) + + test("returns empty array for project without rules", async () => { + await using tmp = await tmpdir() + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(0) + }) + + test("discovers multiple rule sources together", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Legacy file + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + // Directory rules + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules") + // Mode-specific + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const rules = await RulesMigrator.discoverRules(tmp.path) + + expect(rules).toHaveLength(3) + expect(rules.some((r) => r.source === "legacy" && !r.mode)).toBe(true) + expect(rules.some((r) => r.source === "project")).toBe(true) + expect(rules.some((r) => r.mode === "code")).toBe(true) + }) + }) + + describe("migrate", () => { + test("returns instructions array with discovered rules", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(1) + expect(result.instructions[0]).toContain("main.md") + }) + + test("warns about legacy files", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.warnings.some((w) => w.includes("Legacy"))).toBe(true) + expect(result.warnings.some((w) => w.includes(".kilocode/rules/"))).toBe(true) + }) + + test("skips mode-specific rules when includeModeSpecific is false", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const result = await RulesMigrator.migrate({ + projectDir: tmp.path, + includeModeSpecific: false, + }) + + expect(result.instructions).toHaveLength(0) + expect(result.warnings.some((w) => w.includes("skipped"))).toBe(true) + }) + + test("includes mode-specific rules by default", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules-code"), "# Code rules") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(1) + }) + + test("returns empty result for project without rules", async () => { + await using tmp = await tmpdir() + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(0) + expect(result.warnings).toHaveLength(0) + }) + + test("combines all rule sources", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, ".kilocoderules"), "# Legacy") + await fs.mkdir(path.join(dir, ".kilocode", "rules"), { recursive: true }) + await Bun.write(path.join(dir, ".kilocode", "rules", "main.md"), "# Main") + await Bun.write(path.join(dir, ".kilocoderules-architect"), "# Architect") + }, + }) + + const result = await RulesMigrator.migrate({ projectDir: tmp.path }) + + expect(result.instructions).toHaveLength(3) + }) + }) +})