diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 1ec336c2d..2e1c8912c 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -38,21 +38,118 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file; Cursor uses them as-is; Claude Code gets PascalCase in `.claude/settings.json`; OpenCode hooks are generated as a JavaScript plugin at `.opencode/plugins/rulesync-hooks.js`; Gemini CLI gets PascalCase (with some specific name mappings) in `.gemini/settings.json`. +**Hook definition fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `command` | string | Shell command to execute (for command-type hooks) | +| `type` | `"command"` \| `"prompt"` | Hook type (default: `"command"`) | +| `timeout` | number | Timeout in milliseconds | +| `matcher` | string | Pattern to match against (e.g. tool names for `preToolUse`/`postToolUse`) | +| `prompt` | string | Prompt text (for prompt-type hooks) | +| `loop_limit` | number \| null | Maximum number of loop iterations (`null` for unlimited) | +| `name` | string | Hook name identifier | +| `description` | string | Human-readable description of the hook | + **Event support:** -- **Cursor:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `sessionEnd`, `beforeSubmitPrompt`, `subagentStop`, `preCompact`, `afterFileEdit`, `afterShellExecution`, `postToolUseFailure`, `subagentStart`, `beforeShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `beforeReadFile`, `afterAgentResponse`, `afterAgentThought`, `beforeTabFileRead`, `afterTabFileEdit` -- **Claude Code:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `sessionEnd`, `beforeSubmitPrompt`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup`, `worktreeCreate`, `worktreeRemove` +- **Cursor:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `postToolUseFailure`, `subagentStart`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `beforeReadFile`, `afterFileEdit`, `afterAgentResponse`, `afterAgentThought`, `beforeTabFileRead`, `afterTabFileEdit` +- **Claude Code:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup`, `worktreeCreate`, `worktreeRemove` > **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config will be ignored for these events. - **OpenCode:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `afterFileEdit`, `afterShellExecution`, `permissionRequest` +- **Kilo:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `afterFileEdit`, `afterShellExecution`, `permissionRequest` - **GitHub Copilot:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `afterError` - **Gemini CLI:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `stop`, `beforeAgentResponse`, `afterAgentResponse`, `beforeToolSelection`, `preToolUse`, `postToolUse`, `preCompact`, `notification` +- **Codex CLI:** `sessionStart`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop` +- **Factory Droid:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup` +- **DeepAgents:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `permissionRequest`, `postToolUseFailure`, `stop`, `preCompact` > **Note:** Rulesync implements OpenCode hooks as a plugin, so importing from OpenCode to rulesync is not supported. OpenCode only supports command-type hooks (not prompt-type). > **Note:** GitHub Copilot's format uses separate `powershell` and `bash` fields for hooks. Rulesync supports only a single `command` field and resolves this by emitting the command under the `powershell` key on Windows, and under the `bash` key on all other platforms. +Use optional **override keys** so tool-specific events and config live in one file without leaking to others: `cursor.hooks` for Cursor-only events, `claudecode.hooks` for Claude Code-only, `opencode.hooks` for OpenCode-only, `kilo.hooks` for Kilo-only, `copilot.hooks` for GitHub Copilot-only, `geminicli.hooks` for Gemini CLI-only, `codexcli.hooks` for Codex CLI-only, `factorydroid.hooks` for Factory Droid-only, `deepagents.hooks` for DeepAgents-only. Events in shared `hooks` that a tool does not support are skipped for that tool (and a warning is logged at generate time). + +Example: + +```json +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": ".rulesync/hooks/session-start.sh", + "name": "session-init", + "description": "Initialize session environment" + } + ], + "preToolUse": [ + { + "matcher": "Bash", + "type": "prompt", + "prompt": "Review the command before execution for safety", + "description": "Safety check for shell commands" + } + ], + "postToolUse": [ + { + "matcher": "Write|Edit", + "command": ".rulesync/hooks/format.sh", + "timeout": 10000 + } + ], + "stop": [ + { + "command": ".rulesync/hooks/audit.sh", + "loop_limit": null + } + ] + }, + "claudecode": { + "hooks": { + "worktreeCreate": [{ "command": ".claude/hooks/worktree-setup.sh" }] + } + } +} +``` + +> **Note:** The `claudecode.hooks` override is shown above because `worktreeCreate` and `worktreeRemove` are Claude Code-specific events that do not support the `matcher` field. Use tool-specific override keys only for events exclusive to that tool; shared events belong in the top-level `hooks` section. + +**Field support by tool:** + +Not all tools support every hook definition field. Fields listed as unsupported are silently dropped during generation. + +| Field | Cursor | Claude Code | Copilot | Gemini CLI | OpenCode | Kilo | Codex CLI | Factory Droid | DeepAgents | +|-------|--------|-------------|---------|------------|----------|------|-----------|---------------|------------| +| `command` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `type` | ✓ | ✓ | ✗ ^1 | ✓ | ✗ ^1 | ✗ ^1 | ✗ ^1 | ✓ | ✗ ^1 | +| `timeout` | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ ^2 | +| `matcher` | ✓ | ✓ ^3 | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | +| `prompt` | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ ^4 | ✗ | +| `loop_limit` | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | +| `name` | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | +| `description` | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | + +1. Only `"command"` type is supported; `"prompt"` type hooks are skipped. +2. Hardcoded to 5 seconds; custom timeout is not supported. +3. Except `worktreeCreate`/`worktreeRemove` events, which ignore `matcher`. +4. Rulesync outputs the field, but Factory Droid only supports `"command"` type natively. + +**Tool-specific output transformations:** + +| Tool | Unique output keys / behavior | +|------|-------------------------------| +| **GitHub Copilot** | `command` is emitted as `bash` (non-Windows) or `powershell` (Windows); `timeout` is emitted as `timeoutSec` | +| **Gemini CLI** | Hooks are grouped by `matcher` in output; relative commands (starting with `./`) are prefixed with `$GEMINI_PROJECT_DIR/` | +| **Claude Code** | Relative commands are prefixed with `$CLAUDE_PROJECT_DIR/`; `worktreeCreate`/`worktreeRemove` ignore `matcher` | +| **Factory Droid** | Relative commands are prefixed with `$FACTORY_PROJECT_DIR/` | +| **OpenCode / Kilo** | Generated as a JavaScript plugin file (not JSON); only `command`-type hooks are supported | +| **Codex CLI** | Hooks are grouped by `matcher` in output; only `command`-type hooks; generates `.codex/config.toml` with feature flag | +| **DeepAgents** | Output uses flat array structure; `command` is emitted as `["bash", "-c", "..."]`; `matcher` is not supported | + ## `.copilot/mcp-config.json` Example: @@ -85,41 +182,6 @@ This file is used by the GitHub Copilot CLI for MCP server configuration. Rulesy Rulesync preserves explicit `type` values for `http`, `sse`, and `local` servers. For command-based servers that omit a transport type, Rulesync emits the mandatory `"type": "stdio"` field required by the Copilot CLI. -Use optional **override keys** so tool-specific events and config live in one file without leaking to others: `cursor.hooks` for Cursor-only events, `claudecode.hooks` for Claude-only, `opencode.hooks` for OpenCode-only, `copilot.hooks` for GitHub Copilot-only, `geminicli.hooks` for Gemini CLI-only. Events in shared `hooks` that a tool does not support are skipped for that tool (and a warning is logged at generate time). - -Example: - -```json -{ - "version": 1, - "hooks": { - "sessionStart": [{ "type": "command", "command": ".rulesync/hooks/session-start.sh" }], - "postToolUse": [{ "matcher": "Write|Edit", "command": ".rulesync/hooks/format.sh" }], - "stop": [{ "command": ".rulesync/hooks/audit.sh" }] - }, - "cursor": { - "hooks": { - "afterFileEdit": [{ "command": ".cursor/hooks/format.sh" }] - } - }, - "claudecode": { - "hooks": { - "notification": [ - { - "matcher": "permission_prompt", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh" - } - ] - } - }, - "opencode": { - "hooks": { - "afterShellExecution": [{ "command": ".rulesync/hooks/post-shell.sh" }] - } - } -} -``` - ## `rulesync/commands/*.md` Example: diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 1ec336c2d..2e1c8912c 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -38,21 +38,118 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file; Cursor uses them as-is; Claude Code gets PascalCase in `.claude/settings.json`; OpenCode hooks are generated as a JavaScript plugin at `.opencode/plugins/rulesync-hooks.js`; Gemini CLI gets PascalCase (with some specific name mappings) in `.gemini/settings.json`. +**Hook definition fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `command` | string | Shell command to execute (for command-type hooks) | +| `type` | `"command"` \| `"prompt"` | Hook type (default: `"command"`) | +| `timeout` | number | Timeout in milliseconds | +| `matcher` | string | Pattern to match against (e.g. tool names for `preToolUse`/`postToolUse`) | +| `prompt` | string | Prompt text (for prompt-type hooks) | +| `loop_limit` | number \| null | Maximum number of loop iterations (`null` for unlimited) | +| `name` | string | Hook name identifier | +| `description` | string | Human-readable description of the hook | + **Event support:** -- **Cursor:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `sessionEnd`, `beforeSubmitPrompt`, `subagentStop`, `preCompact`, `afterFileEdit`, `afterShellExecution`, `postToolUseFailure`, `subagentStart`, `beforeShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `beforeReadFile`, `afterAgentResponse`, `afterAgentThought`, `beforeTabFileRead`, `afterTabFileEdit` -- **Claude Code:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `sessionEnd`, `beforeSubmitPrompt`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup`, `worktreeCreate`, `worktreeRemove` +- **Cursor:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `postToolUseFailure`, `subagentStart`, `beforeShellExecution`, `afterShellExecution`, `beforeMCPExecution`, `afterMCPExecution`, `beforeReadFile`, `afterFileEdit`, `afterAgentResponse`, `afterAgentThought`, `beforeTabFileRead`, `afterTabFileEdit` +- **Claude Code:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup`, `worktreeCreate`, `worktreeRemove` > **Note:** `worktreeCreate` and `worktreeRemove` are Claude Code-specific events and do not support the `matcher` field. Any matcher defined in the config will be ignored for these events. - **OpenCode:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `afterFileEdit`, `afterShellExecution`, `permissionRequest` +- **Kilo:** `sessionStart`, `preToolUse`, `postToolUse`, `stop`, `afterFileEdit`, `afterShellExecution`, `permissionRequest` - **GitHub Copilot:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, `afterError` - **Gemini CLI:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `stop`, `beforeAgentResponse`, `afterAgentResponse`, `beforeToolSelection`, `preToolUse`, `postToolUse`, `preCompact`, `notification` +- **Codex CLI:** `sessionStart`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop` +- **Factory Droid:** `sessionStart`, `sessionEnd`, `preToolUse`, `postToolUse`, `beforeSubmitPrompt`, `stop`, `subagentStop`, `preCompact`, `permissionRequest`, `notification`, `setup` +- **DeepAgents:** `sessionStart`, `sessionEnd`, `beforeSubmitPrompt`, `permissionRequest`, `postToolUseFailure`, `stop`, `preCompact` > **Note:** Rulesync implements OpenCode hooks as a plugin, so importing from OpenCode to rulesync is not supported. OpenCode only supports command-type hooks (not prompt-type). > **Note:** GitHub Copilot's format uses separate `powershell` and `bash` fields for hooks. Rulesync supports only a single `command` field and resolves this by emitting the command under the `powershell` key on Windows, and under the `bash` key on all other platforms. +Use optional **override keys** so tool-specific events and config live in one file without leaking to others: `cursor.hooks` for Cursor-only events, `claudecode.hooks` for Claude Code-only, `opencode.hooks` for OpenCode-only, `kilo.hooks` for Kilo-only, `copilot.hooks` for GitHub Copilot-only, `geminicli.hooks` for Gemini CLI-only, `codexcli.hooks` for Codex CLI-only, `factorydroid.hooks` for Factory Droid-only, `deepagents.hooks` for DeepAgents-only. Events in shared `hooks` that a tool does not support are skipped for that tool (and a warning is logged at generate time). + +Example: + +```json +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": ".rulesync/hooks/session-start.sh", + "name": "session-init", + "description": "Initialize session environment" + } + ], + "preToolUse": [ + { + "matcher": "Bash", + "type": "prompt", + "prompt": "Review the command before execution for safety", + "description": "Safety check for shell commands" + } + ], + "postToolUse": [ + { + "matcher": "Write|Edit", + "command": ".rulesync/hooks/format.sh", + "timeout": 10000 + } + ], + "stop": [ + { + "command": ".rulesync/hooks/audit.sh", + "loop_limit": null + } + ] + }, + "claudecode": { + "hooks": { + "worktreeCreate": [{ "command": ".claude/hooks/worktree-setup.sh" }] + } + } +} +``` + +> **Note:** The `claudecode.hooks` override is shown above because `worktreeCreate` and `worktreeRemove` are Claude Code-specific events that do not support the `matcher` field. Use tool-specific override keys only for events exclusive to that tool; shared events belong in the top-level `hooks` section. + +**Field support by tool:** + +Not all tools support every hook definition field. Fields listed as unsupported are silently dropped during generation. + +| Field | Cursor | Claude Code | Copilot | Gemini CLI | OpenCode | Kilo | Codex CLI | Factory Droid | DeepAgents | +|-------|--------|-------------|---------|------------|----------|------|-----------|---------------|------------| +| `command` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `type` | ✓ | ✓ | ✗ ^1 | ✓ | ✗ ^1 | ✗ ^1 | ✗ ^1 | ✓ | ✗ ^1 | +| `timeout` | ✓ | ✓ | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ | ✗ ^2 | +| `matcher` | ✓ | ✓ ^3 | ✗ | ✓ | ✓ | ✓ | ✓ | ✓ | ✗ | +| `prompt` | ✓ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ ^4 | ✗ | +| `loop_limit` | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | +| `name` | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | +| `description` | ✗ | ✗ | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ | + +1. Only `"command"` type is supported; `"prompt"` type hooks are skipped. +2. Hardcoded to 5 seconds; custom timeout is not supported. +3. Except `worktreeCreate`/`worktreeRemove` events, which ignore `matcher`. +4. Rulesync outputs the field, but Factory Droid only supports `"command"` type natively. + +**Tool-specific output transformations:** + +| Tool | Unique output keys / behavior | +|------|-------------------------------| +| **GitHub Copilot** | `command` is emitted as `bash` (non-Windows) or `powershell` (Windows); `timeout` is emitted as `timeoutSec` | +| **Gemini CLI** | Hooks are grouped by `matcher` in output; relative commands (starting with `./`) are prefixed with `$GEMINI_PROJECT_DIR/` | +| **Claude Code** | Relative commands are prefixed with `$CLAUDE_PROJECT_DIR/`; `worktreeCreate`/`worktreeRemove` ignore `matcher` | +| **Factory Droid** | Relative commands are prefixed with `$FACTORY_PROJECT_DIR/` | +| **OpenCode / Kilo** | Generated as a JavaScript plugin file (not JSON); only `command`-type hooks are supported | +| **Codex CLI** | Hooks are grouped by `matcher` in output; only `command`-type hooks; generates `.codex/config.toml` with feature flag | +| **DeepAgents** | Output uses flat array structure; `command` is emitted as `["bash", "-c", "..."]`; `matcher` is not supported | + ## `.copilot/mcp-config.json` Example: @@ -85,41 +182,6 @@ This file is used by the GitHub Copilot CLI for MCP server configuration. Rulesy Rulesync preserves explicit `type` values for `http`, `sse`, and `local` servers. For command-based servers that omit a transport type, Rulesync emits the mandatory `"type": "stdio"` field required by the Copilot CLI. -Use optional **override keys** so tool-specific events and config live in one file without leaking to others: `cursor.hooks` for Cursor-only events, `claudecode.hooks` for Claude-only, `opencode.hooks` for OpenCode-only, `copilot.hooks` for GitHub Copilot-only, `geminicli.hooks` for Gemini CLI-only. Events in shared `hooks` that a tool does not support are skipped for that tool (and a warning is logged at generate time). - -Example: - -```json -{ - "version": 1, - "hooks": { - "sessionStart": [{ "type": "command", "command": ".rulesync/hooks/session-start.sh" }], - "postToolUse": [{ "matcher": "Write|Edit", "command": ".rulesync/hooks/format.sh" }], - "stop": [{ "command": ".rulesync/hooks/audit.sh" }] - }, - "cursor": { - "hooks": { - "afterFileEdit": [{ "command": ".cursor/hooks/format.sh" }] - } - }, - "claudecode": { - "hooks": { - "notification": [ - { - "matcher": "permission_prompt", - "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/notify.sh" - } - ] - } - }, - "opencode": { - "hooks": { - "afterShellExecution": [{ "command": ".rulesync/hooks/post-shell.sh" }] - } - } -} -``` - ## `rulesync/commands/*.md` Example: diff --git a/src/features/subagents/copilot-subagent.test.ts b/src/features/subagents/copilot-subagent.test.ts index ae4608f52..cd8503f79 100644 --- a/src/features/subagents/copilot-subagent.test.ts +++ b/src/features/subagents/copilot-subagent.test.ts @@ -96,6 +96,81 @@ Plan tasks`; expect(subagent.getFrontmatter().tools).toEqual(["agent/runSubagent"]); }); + + it("converts .md extension to .agent.md in output file path", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "planner.md", + frontmatter: { + targets: ["copilot"], + name: "planner", + description: "Plan things", + copilot: {}, + }, + body: "Plan tasks", + validate: true, + }); + + const subagent = CopilotSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + validate: true, + }) as CopilotSubagent; + + expect(subagent.getRelativeFilePath()).toBe("planner.agent.md"); + }); + + it("preserves .agent.md extension when already present", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "planner.agent.md", + frontmatter: { + targets: ["copilot"], + name: "planner", + description: "Plan things", + copilot: {}, + }, + body: "Plan tasks", + validate: true, + }); + + const subagent = CopilotSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + validate: true, + }) as CopilotSubagent; + + expect(subagent.getRelativeFilePath()).toBe("planner.agent.md"); + }); + + it("throws when source file path does not end in .md", () => { + const rulesyncSubagent = new RulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + relativeFilePath: "planner.txt", + frontmatter: { + targets: ["copilot"], + name: "planner", + description: "Plan things", + copilot: {}, + }, + body: "Plan tasks", + validate: true, + }); + + expect(() => + CopilotSubagent.fromRulesyncSubagent({ + baseDir: testDir, + relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, + rulesyncSubagent, + validate: true, + }), + ).toThrow("Expected .md file path"); + }); }); describe("toRulesyncSubagent", () => { @@ -124,6 +199,25 @@ Plan tasks`; }); expect(rulesyncSubagent.getBody()).toBe("Plan tasks"); }); + + it("converts .agent.md back to .md for rulesync file path", () => { + const subagent = new CopilotSubagent({ + baseDir: testDir, + relativeDirPath: ".github/agents", + relativeFilePath: "planner.agent.md", + frontmatter: { + name: "planner", + description: "Plan things", + }, + body: "Plan tasks", + fileContent: validContent, + validate: true, + }); + + const rulesyncSubagent = subagent.toRulesyncSubagent(); + + expect(rulesyncSubagent.getRelativeFilePath()).toBe("planner.md"); + }); }); describe("fromFile", () => { diff --git a/src/features/subagents/copilot-subagent.ts b/src/features/subagents/copilot-subagent.ts index 5b4b62a28..4f9e03c8e 100644 --- a/src/features/subagents/copilot-subagent.ts +++ b/src/features/subagents/copilot-subagent.ts @@ -31,6 +31,25 @@ type CopilotSubagentParams = { body: string; } & AiFileParams; +const AGENT_MD_EXTENSION = ".agent.md"; + +const toAgentMdFilePath = (filePath: string): string => { + if (filePath.endsWith(AGENT_MD_EXTENSION)) { + return filePath; + } + if (filePath.endsWith(".md")) { + return filePath.slice(0, -3) + AGENT_MD_EXTENSION; + } + throw new Error(`Expected .md file path, got: ${filePath}`); +}; + +const fromAgentMdFilePath = (filePath: string): string => { + if (filePath.endsWith(AGENT_MD_EXTENSION)) { + return filePath.slice(0, -AGENT_MD_EXTENSION.length) + ".md"; + } + return filePath; +}; + const normalizeTools = (tools: string | string[] | undefined): string[] => { if (!tools) { return []; @@ -98,7 +117,7 @@ export class CopilotSubagent extends ToolSubagent { frontmatter: rulesyncFrontmatter, body: this.body, relativeDirPath: RULESYNC_SUBAGENTS_RELATIVE_DIR_PATH, - relativeFilePath: this.getRelativeFilePath(), + relativeFilePath: fromAgentMdFilePath(this.getRelativeFilePath()), validate: true, }); } @@ -134,7 +153,7 @@ export class CopilotSubagent extends ToolSubagent { frontmatter: copilotFrontmatter, body, relativeDirPath: paths.relativeDirPath, - relativeFilePath: rulesyncSubagent.getRelativeFilePath(), + relativeFilePath: toAgentMdFilePath(rulesyncSubagent.getRelativeFilePath()), fileContent, validate, global, diff --git a/src/features/subagents/subagents-processor.test.ts b/src/features/subagents/subagents-processor.test.ts index 7e4012eb2..67440afb7 100644 --- a/src/features/subagents/subagents-processor.test.ts +++ b/src/features/subagents/subagents-processor.test.ts @@ -646,7 +646,7 @@ description: Copilot agent description --- Copilot agent content`; - await writeFileContent(join(subagentsDir, "copilot-agent.md"), subagentContent); + await writeFileContent(join(subagentsDir, "copilot-agent.agent.md"), subagentContent); const toolFiles = await processor.loadToolFiles(); diff --git a/src/features/subagents/subagents-processor.ts b/src/features/subagents/subagents-processor.ts index 9675b99d2..62f8a0a9a 100644 --- a/src/features/subagents/subagents-processor.ts +++ b/src/features/subagents/subagents-processor.ts @@ -119,7 +119,7 @@ const toolSubagentFactories = new Map