Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,13 @@ Multiple files can set `root: true` for the same target in project and global mo

> **Grok CLI note:** Grok Build writes the root rule to the auto-loaded `AGENTS.md` (project) / `~/.grok/AGENTS.md` (global, via `--global`), and non-root rules to `.grok/rules/*.md` (project) / `~/.grok/rules/*.md` (global). Grok scans that directory flat and in name order, alongside the AGENTS.md family — earlier Rulesync versions folded every topic rule into the single root file, which matched Grok 0.2.54 but not the current release, so regenerate to split them back out. Non-root files carry no frontmatter. Because this is a directory Grok defines rather than one Rulesync invented, a project may already have hand-written files there: Rulesync owns it from now on, so `--delete` removes anything in it — `~/.grok/rules/` included, in global mode — that `.rulesync/rules/` does not produce. Move those files into `.rulesync/rules/` first.

> **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted.
> **Kilo Code note:** Kilo writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.kilo/rules/*.md`. Because Kilo v7 does not auto-load files under `.kilo/rules/`, Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `kilo.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under `.kilo/rules/` — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.

> In global mode (`--global`), Kilo's own layout is asymmetric: the root rule goes to `~/.config/kilo/AGENTS.md`, while non-root rules go to `~/.kilo/rules/*.md` — the same `.kilo`-relative path the skills adapter uses in both scopes. Global rules need no `instructions` registration, because Kilo auto-discovers every `~/.kilo/rules/*.md` on config load; writing the files is enough, and no global `kilo.jsonc` is touched by the rules feature.

> **Kimi Code note:** Kimi Code reads `.kimi-code/AGENTS.md` at project scope and `~/.kimi-code/AGENTS.md` at user scope. When `KIMI_CODE_HOME` is set, Rulesync follows Kimi and resolves every global Kimi-specific file (`AGENTS.md`, `mcp.json`, `config.toml`, `skills/`, and `agents/`) under that custom data root; the shared `~/.agents/skills/` and `~/.agents/agents/` discovery roots remain under the user's real home directory. Because Kimi has no dedicated directory for topic-based instruction files, Rulesync folds every non-root rule body into that single file. See the [Kimi Code agents and instruction-files docs](https://moonshotai.github.io/kimi-code/en/customization/agents.html) and [environment-variable docs](https://moonshotai.github.io/kimi-code/en/configuration/env-vars.html).

> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). The same applies in **global** mode (via `--global`): OpenCode reads `instructions` from the global `~/.config/opencode/opencode.json` too, so global non-root rules are written to `~/.config/opencode/memories/*.md` and registered there (entries relative to the config file's directory, e.g. `memories/style.md`) instead of being dropped. This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved, and the `instructions` list is deduped and sorted.
> **OpenCode note:** OpenCode writes the root rule to the auto-loaded `AGENTS.md` and non-root rules to `.opencode/memories/*.md`. Because OpenCode auto-loads only the root `AGENTS.md` plus files explicitly listed in the `instructions` array of `opencode.json` (it does not auto-discover a rules directory), Rulesync also registers each generated non-root rule file in the `instructions` array of the shared `opencode.json`/`opencode.jsonc` (the root `AGENTS.md` is auto-loaded and is therefore not registered). The same applies in **global** mode (via `--global`): OpenCode reads `instructions` from the global `~/.config/opencode/opencode.json` too, so global non-root rules are written to `~/.config/opencode/memories/*.md` and registered there (entries relative to the config file's directory, e.g. `memories/style.md`) instead of being dropped. This merge is non-destructive: existing keys such as `mcp`, `tools`, and `permission` are preserved. Within the `instructions` list rulesync owns the entries under its managed rules directory (`.opencode/memories/`, or `memories/` in the global config) — that subset is rebuilt from the current generate, so deleting a rule also drops its registration — while entries outside it pass through verbatim; the result is deduped and sorted.

> **Qwen Code note:** Qwen Code writes the root rule to the auto-loaded `QWEN.md` (project) / `~/.qwen/QWEN.md` (global, via `--global`) as plain Markdown, and non-root rules to its path-based context-rule directory `.qwen/rules/` (project) / `~/.qwen/rules/` (global). Each non-root rule is a Markdown file with optional YAML frontmatter: Rulesync maps `globs` ⇄ Qwen's `paths` (a picomatch glob array) and `description` ⇄ `description`. A rule **with** specific `paths` is _conditional_ — Qwen lazily injects it only when the model touches a matching file — while a rule **without** `paths` (empty or wildcard `**/*`/`*` globs) is a _baseline_ rule loaded at session start and is written as plain Markdown with no frontmatter block. The `.qwen/rules/` directory supersedes the legacy `.qwen/memories/` import surface, so each rule is emitted to exactly one location; the root `QWEN.md` is unchanged. A `localRoot: true` rule is emitted to `.qwen/QWEN.local.md` (project scope only) — Qwen Code v0.16.2's personal project context file, loaded after the shared `QWEN.md` so it can override team instructions; the file is covered by the derived `.gitignore` since Qwen Code does not gitignore it for you. See the [Qwen Code memory/context docs](https://github.com/QwenLM/qwen-code).

Expand Down
11 changes: 8 additions & 3 deletions src/features/mcp/kilo-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2321,6 +2321,7 @@ describe("KiloMcp", () => {
outputRoot: testDir,
instructions: [".kilo/rules/overview.md"],
});
if (kiloMcp === null) throw new Error("expected a registrar result");

const json = kiloMcp.getJson();
expect(json.mcp).toEqual(existingConfig.mcp);
Expand All @@ -2329,21 +2330,24 @@ describe("KiloMcp", () => {
expect(json.instructions).toEqual([".kilo/rules/overview.md"]);
});

it("should dedupe and sort merged instructions", async () => {
it("should own the managed rules entries and preserve user entries verbatim", async () => {
const existingConfig = {
instructions: [".kilo/rules/b.md", ".kilo/rules/a.md"],
// The stale a.md entry (its rule was deleted) must not accumulate;
// the user's own docs/style.md passes through verbatim.
instructions: [".kilo/rules/b.md", ".kilo/rules/a.md", "docs/style.md"],
};
await writeFileContent(join(testDir, "kilo.jsonc"), JSON.stringify(existingConfig, null, 2));

const kiloMcp = await KiloMcp.fromInstructions({
outputRoot: testDir,
instructions: [".kilo/rules/b.md", ".kilo/rules/c.md"],
});
if (kiloMcp === null) throw new Error("expected a registrar result");

expect(kiloMcp.getJson().instructions).toEqual([
".kilo/rules/a.md",
".kilo/rules/b.md",
".kilo/rules/c.md",
"docs/style.md",
]);
});

Expand All @@ -2352,6 +2356,7 @@ describe("KiloMcp", () => {
outputRoot: testDir,
instructions: [".kilo/rules/overview.md"],
});
if (kiloMcp === null) throw new Error("expected a registrar result");

expect(kiloMcp.getRelativeFilePath()).toBe("kilo.jsonc");
expect(kiloMcp.getJson().instructions).toEqual([".kilo/rules/overview.md"]);
Expand Down
27 changes: 23 additions & 4 deletions src/features/mcp/kilo-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { refine, z } from "zod/mini";
import {
KILO_DIR,
KILO_GLOBAL_DIR,
KILO_RULES_DIR_NAME,
KILO_JSON_FILE_NAME,
KILO_JSONC_FILE_NAME,
} from "../../constants/kilo-paths.js";
import { ValidationResult } from "../../types/ai-file.js";
import { McpServers } from "../../types/mcp.js";
import { readFileContentOrNull } from "../../utils/file.js";
import { readFileContentOrNull, toPosixPath } from "../../utils/file.js";
import type { Logger } from "../../utils/logger.js";
import { isRecord } from "../../utils/type-guards.js";
import { applySharedConfigPatch, sharedConfigFileKey } from "../shared/shared-config-gateway.js";
Expand Down Expand Up @@ -652,7 +653,7 @@ export class KiloMcp extends ToolMcp {
instructions: string[];
validate?: boolean;
global?: boolean;
}): Promise<KiloMcp> {
}): Promise<KiloMcp | null> {
const basePaths = this.getSettablePaths({ global });
const jsonDir = join(outputRoot, basePaths.relativeDirPath);

Expand All @@ -671,13 +672,30 @@ export class KiloMcp extends ToolMcp {
}
}

// Nothing to register and nothing to clean up: do not create the shared
// config just to hold an empty payload.
if (instructions.length === 0 && fileContent === null) {
return null;
}

const json = fileContent ? parseJsonc(fileContent) : {};
const existingInstructions: string[] = Array.isArray(json.instructions)
? json.instructions.filter((entry: unknown): entry is string => typeof entry === "string")
: [];

// rulesync owns the entries under its managed rules directory — rebuilt
// from the current generate so deleted rules do not leave stale entries;
// entries outside it are the user's and pass through verbatim. Project
// scope only today (Kilo auto-discovers its global rules dir, so the
// registrar never runs globally); a future global opt-in must not reuse
// this project prefix.
const managedPrefix = `${toPosixPath(join(KILO_DIR, KILO_RULES_DIR_NAME))}/`;
const preservedInstructions = existingInstructions.filter(
(entry) => !toPosixPath(entry).replace(/^\.\//, "").startsWith(managedPrefix),
);

const mergedInstructions = Array.from(
new Set([...existingInstructions, ...instructions]),
new Set([...preservedInstructions, ...instructions]),
).toSorted();

return new KiloMcp({
Expand All @@ -688,7 +706,8 @@ export class KiloMcp extends ToolMcp {
fileKey: sharedConfigFileKey(basePaths),
feature: "rules",
existingContent: fileContent ?? "",
patch: { instructions: mergedInstructions },
// An emptied list retracts the key rather than writing `[]`.
patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : undefined },
filePath: join(jsonDir, relativeFilePath),
}),
validate,
Expand Down
60 changes: 57 additions & 3 deletions src/features/mcp/opencode-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2578,6 +2578,7 @@ describe("OpencodeMcp", () => {
outputRoot: testDir,
instructions: [".opencode/memories/overview.md"],
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

const json = opencodeMcp.getJson();
expect(json.mcp).toEqual(existingConfig.mcp);
Expand All @@ -2587,9 +2588,16 @@ describe("OpencodeMcp", () => {
expect(opencodeMcp.getRelativeFilePath()).toBe("opencode.jsonc");
});

it("should dedupe and sort merged instructions", async () => {
it("should own the managed memories entries and preserve user entries verbatim", async () => {
const existingConfig = {
instructions: [".opencode/memories/b.md", ".opencode/memories/a.md"],
instructions: [
// Managed dir: rebuilt from the current generate, so the entry for
// the since-deleted a.md must not accumulate forever.
".opencode/memories/b.md",
".opencode/memories/a.md",
// User entries outside the managed dir pass through verbatim.
"docs/style.md",
],
};
await writeFileContent(
join(testDir, "opencode.jsonc"),
Expand All @@ -2600,20 +2608,64 @@ describe("OpencodeMcp", () => {
outputRoot: testDir,
instructions: [".opencode/memories/b.md", ".opencode/memories/c.md"],
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

expect((opencodeMcp.getJson() as any).instructions).toEqual([
".opencode/memories/a.md",
".opencode/memories/b.md",
".opencode/memories/c.md",
"docs/style.md",
]);
});

it("should clear stale managed entries when the last rule is deleted", async () => {
await writeFileContent(
join(testDir, "opencode.jsonc"),
JSON.stringify({
instructions: [".opencode/memories/stale.md", "docs/style.md"],
mcp: {},
}),
);

const opencodeMcp = await OpencodeMcp.fromInstructions({
outputRoot: testDir,
instructions: [],
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

const json = opencodeMcp.getJson() as Record<string, unknown>;
// The managed entry is owned and rebuilt (to nothing); the user entry
// survives; an all-user list keeps the key, and mcp is untouched.
expect(json.instructions).toEqual(["docs/style.md"]);
expect(json.mcp).toEqual({});
});

it("should drop the legacy full-prefix global spelling as a managed duplicate", async () => {
await ensureDir(join(testDir, ".config", "opencode"));
await writeFileContent(
join(testDir, ".config", "opencode", "opencode.json"),
JSON.stringify({
instructions: [".config/opencode/memories/old.md", "memories/stale.md", "docs/user.md"],
}),
);

const opencodeMcp = await OpencodeMcp.fromInstructions({
outputRoot: testDir,
instructions: [".config/opencode/memories/style.md"],
global: true,
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

const json = opencodeMcp.getJson() as Record<string, unknown>;
expect(json.instructions).toEqual(["docs/user.md", "memories/style.md"]);
});

it("should register global instructions relative to the global config dir", async () => {
const opencodeMcp = await OpencodeMcp.fromInstructions({
outputRoot: testDir,
instructions: [".config/opencode/memories/overview.md"],
global: true,
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

expect(opencodeMcp.getRelativeDirPath()).toBe(join(".config", "opencode"));
const parsed = JSON.parse(opencodeMcp.getFileContent());
Expand All @@ -2629,6 +2681,7 @@ describe("OpencodeMcp", () => {
outputRoot: testDir,
instructions: [".opencode/memories/overview.md"],
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

expect(opencodeMcp.getRelativeFilePath()).toBe("opencode.json");
expect((opencodeMcp.getJson() as any).instructions).toEqual([
Expand All @@ -2641,6 +2694,7 @@ describe("OpencodeMcp", () => {
outputRoot: testDir,
instructions: [".opencode/memories/overview.md"],
});
if (opencodeMcp === null) throw new Error("expected a registrar result");

expect(opencodeMcp.getRelativeFilePath()).toBe("opencode.jsonc");
expect((opencodeMcp.getJson() as any).instructions).toEqual([
Expand Down
27 changes: 24 additions & 3 deletions src/features/mcp/opencode-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { parse as parseJsonc } from "jsonc-parser";
import { refine, z } from "zod/mini";

import {
OPENCODE_DIR,
OPENCODE_GLOBAL_DIR,
OPENCODE_JSON_FILE_NAME,
OPENCODE_JSONC_FILE_NAME,
Expand Down Expand Up @@ -603,7 +604,7 @@ export class OpencodeMcp extends ToolMcp {
instructions: string[];
validate?: boolean;
global?: boolean;
}): Promise<OpencodeMcp> {
}): Promise<OpencodeMcp | null> {
const basePaths = this.getSettablePaths({ global });
const jsonDir = join(outputRoot, basePaths.relativeDirPath);

Expand Down Expand Up @@ -631,13 +632,32 @@ export class OpencodeMcp extends ToolMcp {
}
}

// Nothing to register and nothing to clean up: do not create the shared
// config just to hold an empty payload.
if (instructions.length === 0 && fileContent === null) {
return null;
}

const json = fileContent ? parseJsonc(fileContent) : {};
const existingInstructions: string[] = Array.isArray(json.instructions)
? json.instructions.filter((entry: unknown): entry is string => typeof entry === "string")
: [];

// rulesync owns the entries that point under its managed rules directory:
// that subset is rebuilt from the current generate, so an entry registered
// for a since-deleted rule does not accumulate forever (and a legacy
// full-prefix global spelling cannot coexist as a duplicate). Entries
// outside the managed directory are the user's and pass through verbatim.
const managedPrefixes = global
? ["memories/", `${configDirPrefix}memories/`]
: [`${toPosixPath(OPENCODE_DIR)}/memories/`];
const preservedInstructions = existingInstructions.filter((entry) => {
const normalized = toPosixPath(entry).replace(/^\.\//, "");
return !managedPrefixes.some((prefix) => normalized.startsWith(prefix));
});

const mergedInstructions = Array.from(
new Set([...existingInstructions, ...normalizedInstructions]),
new Set([...preservedInstructions, ...normalizedInstructions]),
).toSorted();

return new OpencodeMcp({
Expand All @@ -648,7 +668,8 @@ export class OpencodeMcp extends ToolMcp {
fileKey: sharedConfigFileKey(basePaths),
feature: "rules",
existingContent: fileContent ?? "",
patch: { instructions: mergedInstructions },
// An emptied list retracts the key rather than writing `[]`.
patch: { instructions: mergedInstructions.length > 0 ? mergedInstructions : undefined },
filePath: join(jsonDir, relativeFilePath),
}),
validate,
Expand Down
24 changes: 12 additions & 12 deletions src/features/rules/rules-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ type McpInstructionsRegistrar = {
instructions: string[];
validate?: boolean;
global?: boolean;
}): Promise<ToolFile>;
}): Promise<ToolFile | null>;
};

type LocalRootMode = "separate-local-file" | "append-to-root";
Expand Down Expand Up @@ -1090,17 +1090,17 @@ export class RulesProcessor extends FeatureProcessor {
const instructionPaths = toolRules
.filter((rule) => !rule.isRoot() && !rule.isExcludedFromRootReferences())
.map((rule) => toPosixPath(join(rule.getRelativeDirPath(), rule.getRelativeFilePath())));
if (instructionPaths.length === 0) {
return [];
}
return [
await meta.mcpInstructionsRegistrar.fromInstructions({
outputRoot: this.outputRoot,
instructions: instructionPaths,
validate: true,
global: this.global,
}),
];
// The registrar runs even with an empty list: it owns the managed subset
// of the `instructions` array, so deleting the LAST non-root rule must
// still clear its stale registrations (the registrar itself avoids
// creating a config file just to hold an empty payload).
const registered = await meta.mcpInstructionsRegistrar.fromInstructions({
outputRoot: this.outputRoot,
instructions: instructionPaths,
validate: true,
global: this.global,
});
return registered ? [registered] : [];
}

/**
Expand Down
Loading
Loading