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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,7 @@ rulesync.local.jsonc
**/.windsurf/hooks.json
**/.devin/skills/
**/.codeium/windsurf/skills/
**/.devin/agents/
**/.vibeignore
**/.vibe/skills/
**/.vibe/agents/
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ The tables below show whether each tool supports a given feature (✅ = supporte
| Google Antigravity ⚠️ | ✅ | | | ✅ | | ✅ | | |
| JetBrains Junie | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| AugmentCode | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| Devin Desktop | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | |
| Devin Desktop | ✅ | ✅ | ✅ | ✅ | | ✅ | ✅ | |
| Warp | ✅ | | ✅ | | | ✅ | | ✅ |
| Replit | ✅ | | | | | ✅ | | |
| Pi Coding Agent | ✅ | | | ✅ | | ✅ | | |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ Attention, again, you are just the planner, so though you can read any files and

> **Cline note:** Cline file-based agents are emitted as YAML files (`<name>.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader.

> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents/<name>/AGENT.md` (project) and `~/.config/devin/agents/<name>/AGENT.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents).

> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior.

Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip):
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | |
| JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | |
| AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | |
| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Warp | warp | ✅ | | ✅ 🌏 | | | ✅ 🌏 | | 🌏 |
| Replit | replit | ✅ | | | | | ✅ 🌏 | | |
| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | |
Expand Down
2 changes: 2 additions & 0 deletions skills/rulesync/file-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,8 @@ Attention, again, you are just the planner, so though you can read any files and

> **Cline note:** Cline file-based agents are emitted as YAML files (`<name>.yaml`) into `.cline/agents/` (project) and `~/.cline/agents/` (global, via `--global`). The file is a YAML frontmatter block (`name` required, `description`) followed by the system prompt body, matching Cline's agent config loader.

> **Devin note:** Devin Local custom subagent profiles are emitted as `AGENT.md` files in a **directory-per-agent** layout: `.devin/agents/<name>/AGENT.md` (project) and `~/.config/devin/agents/<name>/AGENT.md` (global, via `--global`). The directory name `<name>` is the profile id (derived from the rulesync subagent file name). The `AGENT.md` is a YAML frontmatter block followed by the subagent's system prompt. Besides the shared `name`/`description`, the `devin` subagent block accepts these optional fields (all preserved on round-trip): `model` (string, override the subagent LLM), `allowed-tools` (list of strings, restrict available tools), `permissions` (object with `allow`/`deny`/`ask` string lists, override tool permissions), and `max-nesting` (integer, enable nested subagent spawning up to the given depth). See the [Devin subagents docs](https://docs.devin.ai/cli/subagents).

> **Kilo note (as of 2026-05-13):** Kilo's documented default for user-defined agents is `mode: all`, which makes the agent available both as a top-level pick and as a subagent. Set `kilo.mode: subagent` to opt into hidden/subagent-only behavior.

Besides `mode`, the `kilo` subagent block accepts these optional fields (all preserved on round-trip):
Expand Down
2 changes: 1 addition & 1 deletion skills/rulesync/supported-tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Rulesync supports both **generation** and **import** for All of the major AI cod
| Google Antigravity ⚠️ | antigravity | ✅ | | | ✅ | | ✅ 🌏 | | |
| JetBrains Junie | junie | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | 🌏 | |
| AugmentCode | augmentcode | ✅ 🌏 | ✅ | 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 |
| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | | ✅ 🌏 | ✅ 🌏 | |
| Devin Desktop | devin | ✅ 🌏 | ✅ | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | ✅ 🌏 | |
| Warp | warp | ✅ | | ✅ 🌏 | | | ✅ 🌏 | | 🌏 |
| Replit | replit | ✅ | | | | | ✅ 🌏 | | |
| Pi Coding Agent | pi | ✅ 🌏 | | | ✅ 🌏 | | ✅ 🌏 | | |
Expand Down
4 changes: 4 additions & 0 deletions src/cli/commands/gitignore-entries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,10 @@ export const GITIGNORE_ENTRY_REGISTRY: ReadonlyArray<GitignoreEntryTag> = [
{ target: "devin", feature: "hooks", entry: "**/.windsurf/hooks.json" },
{ target: "devin", feature: "skills", entry: "**/.devin/skills/" },
{ target: "devin", feature: "skills", entry: "**/.codeium/windsurf/skills/" },
// Devin Local custom subagent profiles: `.devin/agents/<name>/AGENT.md`
// (project). The global path `~/.config/devin/agents/` lives under the home
// directory and is not gitignored at the project level.
{ target: "devin", feature: "subagents", entry: "**/.devin/agents/" },

// Vibe
{ target: "vibe", feature: "ignore", entry: "**/.vibeignore" },
Expand Down
6 changes: 6 additions & 0 deletions src/constants/devin-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ export const CODEIUM_WINDSURF_DIR = join(CODEIUM_DIR, WINDSURF_SUBDIR);
export const WINDSURF_MEMORIES_SUBDIR = join(WINDSURF_SUBDIR, "memories");
export const DEVIN_WORKFLOWS_DIR_PATH = join(DEVIN_DIR, "workflows");
export const DEVIN_SKILLS_DIR_PATH = join(DEVIN_DIR, "skills");
export const DEVIN_AGENTS_DIR_PATH = join(DEVIN_DIR, "agents");
// Devin Local custom subagent profiles in global mode live under
// `~/.config/devin/agents/` (NOT `.devin/agents/` under the home directory).
// The home directory is resolved by the processor through outputRoot in global mode.
// https://docs.devin.ai/cli/subagents
export const DEVIN_GLOBAL_AGENTS_DIR_PATH = join(".config", "devin", "agents");
export const CODEIUM_WINDSURF_GLOBAL_WORKFLOWS_DIR_PATH = join(
CODEIUM_WINDSURF_DIR,
"global_workflows",
Expand Down
10 changes: 10 additions & 0 deletions src/e2e/e2e-subagents.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ describe("E2E: subagents", () => {
target: "deepagents",
outputPath: join(".deepagents", "agents", "planner", "AGENTS.md"),
},
{
target: "devin",
outputPath: join(".devin", "agents", "planner", "AGENT.md"),
},
{
target: "kiro",
outputPath: join(".kiro", "agents", "planner.json"),
Expand Down Expand Up @@ -229,6 +233,7 @@ You are a subagent-only helper.
{ target: "codexcli", orphanPath: join(".codex", "agents", "orphan.toml") },
{ target: "copilot", orphanPath: join(".github", "agents", "orphan.md") },
{ target: "deepagents", orphanPath: join(".deepagents", "agents", "orphan", "AGENTS.md") },
{ target: "devin", orphanPath: join(".devin", "agents", "orphan", "AGENT.md") },
{ target: "kiro", orphanPath: join(".kiro", "agents", "orphan.json") },
{ target: "kiro-cli", orphanPath: join(".kiro", "agents", "orphan.json") },
{ target: "kiro-ide", orphanPath: join(".kiro", "agents", "orphan.md") },
Expand Down Expand Up @@ -278,6 +283,7 @@ describe("E2E: subagents (import)", () => {
{ target: "junie", sourcePath: join(".junie", "agents", "planner.md") },
{ target: "factorydroid", sourcePath: join(".factory", "droids", "planner.md") },
{ target: "cline", sourcePath: join(".cline", "agents", "planner.yaml") },
{ target: "devin", sourcePath: join(".devin", "agents", "planner", "AGENT.md") },
])("should import $target subagents", async ({ target, sourcePath }) => {
const testDir = getTestDir();

Expand Down Expand Up @@ -439,6 +445,10 @@ describe("E2E: subagents (global mode)", () => {
target: "deepagents",
outputPath: join(".deepagents", "deepagents", "agents", "planner", "AGENTS.md"),
},
{
target: "devin",
outputPath: join(".config", "devin", "agents", "planner", "AGENT.md"),
},
{ target: "vibe", outputPath: join(".vibe", "agents", "planner.toml") },
{
target: "goose",
Expand Down
265 changes: 265 additions & 0 deletions src/features/subagents/devin-subagent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,265 @@
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { setupTestDirectory } from "../../test-utils/test-directories.js";
import { ensureDir, writeFileContent } from "../../utils/file.js";
import { DevinSubagent } from "./devin-subagent.js";
import { RulesyncSubagent } from "./rulesync-subagent.js";

describe("DevinSubagent", () => {
let testDir: string;
let cleanup: () => Promise<void>;

beforeEach(async () => {
({ testDir, cleanup } = await setupTestDirectory());
vi.spyOn(process, "cwd").mockReturnValue(testDir);
});

afterEach(async () => {
await cleanup();
vi.restoreAllMocks();
});

describe("getSettablePaths", () => {
it("should return .devin/agents for project mode", () => {
const paths = DevinSubagent.getSettablePaths();
expect(paths.relativeDirPath).toBe(join(".devin", "agents"));
});

it("should return ~/.config/devin/agents for global mode", () => {
const paths = DevinSubagent.getSettablePaths({ global: true });
expect(paths.relativeDirPath).toBe(join(".config", "devin", "agents"));
});
});

describe("constructor", () => {
it("should create with name and description", () => {
const subagent = new DevinSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
relativeFilePath: join("my-agent", "AGENT.md"),
frontmatter: { name: "My Agent", description: "Does useful things." },
body: "You are a helpful agent.",
});

expect(subagent.getFrontmatter().name).toBe("My Agent");
expect(subagent.getBody()).toBe("You are a helpful agent.");
});

it("should create with Devin-specific optional fields", () => {
const subagent = new DevinSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
relativeFilePath: join("my-agent", "AGENT.md"),
frontmatter: {
name: "Agent",
description: "Desc.",
model: "claude-sonnet-4-6",
"allowed-tools": ["read", "search"],
permissions: { allow: ["read"], deny: ["write"] },
"max-nesting": 2,
},
body: "System prompt.",
});

expect(subagent.getFrontmatter().model).toBe("claude-sonnet-4-6");
expect(subagent.getFrontmatter()["allowed-tools"]).toEqual(["read", "search"]);
expect(subagent.getFrontmatter().permissions).toEqual({
allow: ["read"],
deny: ["write"],
});
expect(subagent.getFrontmatter()["max-nesting"]).toBe(2);
});
});

describe("fromFile", () => {
it("should read subagent from .devin/agents/<name>/AGENT.md", async () => {
const agentDir = join(testDir, ".devin", "agents", "test-agent");
await ensureDir(agentDir);
const content = `---
name: Test Agent
description: A test agent.
model: claude-haiku-4-5-20251001
---

You are a test agent.`;
await writeFileContent(join(agentDir, "AGENT.md"), content);

const subagent = await DevinSubagent.fromFile({
outputRoot: testDir,
relativeFilePath: join("test-agent", "AGENT.md"),
});

expect(subagent.getFrontmatter().name).toBe("Test Agent");
expect(subagent.getFrontmatter().model).toBe("claude-haiku-4-5-20251001");
expect(subagent.getBody()).toBe("You are a test agent.");
});
});

describe("fromRulesyncSubagent", () => {
it("should map name and description and emit <name>/AGENT.md", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "my-agent.md",
frontmatter: { name: "My Agent", description: "Does things.", targets: ["devin"] },
body: "You are an agent.",
});

const subagent = DevinSubagent.fromRulesyncSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
rulesyncSubagent,
}) as DevinSubagent;

expect(subagent.getFrontmatter().name).toBe("My Agent");
expect(subagent.getFrontmatter().description).toBe("Does things.");
expect(subagent.getRelativeDirPath()).toBe(join(".devin", "agents"));
expect(subagent.getRelativeFilePath()).toBe(join("my-agent", "AGENT.md"));
});

it("should pull Devin-specific fields from the devin tool-specific section", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "my-agent.md",
frontmatter: {
name: "Agent",
description: "Desc.",
targets: ["devin"],
devin: {
model: "claude-sonnet-4-6",
"allowed-tools": ["read"],
"max-nesting": 1,
},
},
body: "System prompt.",
});

const subagent = DevinSubagent.fromRulesyncSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
rulesyncSubagent,
}) as DevinSubagent;

expect(subagent.getFrontmatter().model).toBe("claude-sonnet-4-6");
expect(subagent.getFrontmatter()["allowed-tools"]).toEqual(["read"]);
expect(subagent.getFrontmatter()["max-nesting"]).toBe(1);
});

it("should emit <name>/AGENT.md in global mode", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "my-agent.md",
frontmatter: { name: "My Agent", description: "Does things.", targets: ["devin"] },
body: "You are an agent.",
});

const subagent = DevinSubagent.fromRulesyncSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
rulesyncSubagent,
global: true,
}) as DevinSubagent;

expect(subagent.getRelativeDirPath()).toBe(join(".config", "devin", "agents"));
expect(subagent.getRelativeFilePath()).toBe(join("my-agent", "AGENT.md"));
});
});

describe("toRulesyncSubagent", () => {
it("should convert back to rulesync subagent preserving name and body", () => {
const subagent = new DevinSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
relativeFilePath: join("my-agent", "AGENT.md"),
frontmatter: { name: "My Agent", description: "Does things.", model: "claude-sonnet-4-6" },
body: "You are an agent.",
});

const rulesyncSubagent = subagent.toRulesyncSubagent();
const frontmatter = rulesyncSubagent.getFrontmatter();

expect(frontmatter.name).toBe("My Agent");
expect(frontmatter.description).toBe("Does things.");
expect(rulesyncSubagent.getBody()).toBe("You are an agent.");
// The directory name (not the AGENT.md filename) becomes the flat rulesync file.
expect(rulesyncSubagent.getRelativeFilePath()).toBe("my-agent.md");
});

it("should store Devin-specific fields in the devin tool-specific section", () => {
const subagent = new DevinSubagent({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
relativeFilePath: join("my-agent", "AGENT.md"),
frontmatter: {
name: "Agent",
description: "Desc.",
model: "claude-haiku-4-5-20251001",
"max-nesting": 3,
},
body: "System prompt.",
});

const rulesyncSubagent = subagent.toRulesyncSubagent();
const devinSection = rulesyncSubagent.getFrontmatter().devin as Record<string, unknown>;

expect(devinSection?.model).toBe("claude-haiku-4-5-20251001");
expect(devinSection?.["max-nesting"]).toBe(3);
});
});

describe("forDeletion", () => {
it("should create a deletable placeholder for <name>/AGENT.md", () => {
const subagent = DevinSubagent.forDeletion({
outputRoot: testDir,
relativeDirPath: join(".devin", "agents"),
relativeFilePath: join("orphan", "AGENT.md"),
});

expect(subagent.getRelativeDirPath()).toBe(join(".devin", "agents"));
expect(subagent.getRelativeFilePath()).toBe(join("orphan", "AGENT.md"));
expect(subagent.getBody()).toBe("");
});
});

describe("isTargetedByRulesyncSubagent", () => {
it("should return true for devin target", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "agent.md",
frontmatter: { name: "Agent", targets: ["devin"] },
body: "",
});

expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true);
});

it("should return true for wildcard target", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "agent.md",
frontmatter: { name: "Agent", targets: ["*"] },
body: "",
});

expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(true);
});

it("should return false for different tool", () => {
const rulesyncSubagent = new RulesyncSubagent({
outputRoot: testDir,
relativeDirPath: ".rulesync/subagents",
relativeFilePath: "agent.md",
frontmatter: { name: "Agent", targets: ["claudecode"] },
body: "",
});

expect(DevinSubagent.isTargetedByRulesyncSubagent(rulesyncSubagent)).toBe(false);
});
});
});
Loading