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
This commit is contained in:
Marius Wichtner
2026-01-26 17:50:58 +01:00
parent d2b05852df
commit 7471fcfe73
7 changed files with 588 additions and 0 deletions
+21
View File
@@ -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 || []
@@ -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<InjectionResult> {
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,
@@ -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
+1
View File
@@ -1,2 +1,3 @@
export { ModesMigrator } from "./modes-migrator"
export { RulesMigrator } from "./rules-migrator" // kilocode_change
export { KilocodeConfigInjector } from "./config-injector"
@@ -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<boolean> {
return Bun.file(filepath).exists()
}
async function isDirectory(filepath: string): Promise<boolean> {
try {
const stat = await fs.stat(filepath)
return stat.isDirectory()
} catch {
return false
}
}
async function findMarkdownFiles(dir: string): Promise<string[]> {
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<RuleFile[]> {
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<MigrationResult> {
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 }
}
}
@@ -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", () => {
@@ -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)
})
})
})