diff --git a/docs/reference/file-formats.md b/docs/reference/file-formats.md index 9762054d7..9d1357804 100644 --- a/docs/reference/file-formats.md +++ b/docs/reference/file-formats.md @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). Example: @@ -136,9 +136,9 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | | `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | | `postToolUseFailure` | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | ✅ | -| `stop` | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `stop` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | -| `subagentStop` | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | +| `subagentStop` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | | `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | | `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | | `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | @@ -230,6 +230,7 @@ description: "Review a pull request" # command description targets: ["*"] # * = all, or specific tools copilot: # copilot specific parameters (optional) description: "Review a pull request" + agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode") antigravity: # antigravity specific parameters trigger: "/review" # Specific trigger for workflow (renames file to review.md) turbo: true # (Optional, default: true) Append // turbo for auto-execution diff --git a/skills/rulesync/file-formats.md b/skills/rulesync/file-formats.md index 9762054d7..9d1357804 100644 --- a/skills/rulesync/file-formats.md +++ b/skills/rulesync/file-formats.md @@ -62,7 +62,7 @@ This is Rulesync, a Node.js CLI tool that automatically generates configuration ## `.rulesync/hooks.json` -Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). +Hooks run scripts at lifecycle events (e.g. session start, before tool use). Events use **canonical camelCase** in this file, and Rulesync translates them per tool: Cursor uses them as-is; Claude Code, Factory Droid, Codex CLI, Gemini CLI, and Goose get PascalCase (with a few tool-specific name mappings) in their settings files; OpenCode and Kilo hooks are emitted as JavaScript plugins (`.opencode/plugins/rulesync-hooks.js`, `.kilo/plugins/rulesync-hooks.js`); Copilot and Copilot CLI map event names to their own camelCase (e.g. `beforeSubmitPrompt` → `userPromptSubmitted`, `stop` → `agentStop`, `afterError` → `errorOccurred`) and use `powershell`/`bash` command fields — Copilot CLI additionally covers a wider event set and supports `prompt` and `http` hook types beyond `command`; deepagents-cli uses a dot-notation (e.g. `session.start`, `tool.error`); Kiro emits hooks into `.kiro/agents/default.json` using Kiro's CLI event names (`agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse`, `stop`). Example: @@ -136,9 +136,9 @@ Events present in the shared `hooks` block but unsupported by a given tool are s | `preModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | | `postModelInvocation` | — | — | — | — | — | — | — | — | — | — | — | ✅ | ✅ | — | — | — | | `postToolUseFailure` | ✅ | — | — | — | — | ✅ | — | — | — | ✅ | — | — | — | — | — | ✅ | -| `stop` | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | +| `stop` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ | | `subagentStart` | ✅ | — | — | — | — | ✅ | — | — | ✅ | — | — | — | — | — | — | ✅ | -| `subagentStop` | ✅ | ✅ | — | — | — | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | +| `subagentStop` | ✅ | ✅ | — | — | ✅ | ✅ | ✅ | — | ✅ | — | — | — | — | — | — | ✅ | | `preCompact` | ✅ | ✅ | — | — | — | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | — | — | — | — | | `postCompact` | — | — | — | — | — | — | — | — | ✅ | — | — | — | — | — | — | — | | `afterFileEdit` | ✅ | — | ✅ | ✅ | — | — | — | — | — | — | — | — | — | ✅ | — | ✅ | @@ -230,6 +230,7 @@ description: "Review a pull request" # command description targets: ["*"] # * = all, or specific tools copilot: # copilot specific parameters (optional) description: "Review a pull request" + agent: "agent" # (optional) VS Code prompt-file agent: "ask", "agent", "plan", or a custom agent name (replaces the deprecated "mode") antigravity: # antigravity specific parameters trigger: "/review" # Specific trigger for workflow (renames file to review.md) turbo: true # (Optional, default: true) Append // turbo for auto-execution diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 15a3cc895..5151cc07d 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -97,13 +97,14 @@ describe("E2E: hooks", () => { expect(parsed.hooks.agentSpawn[0].command).toBe(".rulesync/hooks/session-start.sh"); expect(parsed.hooks.stop[0].command).toBe(".rulesync/hooks/audit.sh"); } else if (target === "copilot" || target === "copilotcli") { - // Copilot and Copilot CLI use camelCase event names. Neither supports - // the `stop` hook event (see COPILOT_HOOK_EVENTS in src/types/hooks.ts), - // so audit.sh is intentionally dropped during generation and cannot be - // asserted here. + // Copilot and Copilot CLI use camelCase event names and both map the + // canonical `stop` event to `agentStop` (see COPILOT_HOOK_EVENTS / + // COPILOTCLI_HOOK_EVENTS in src/types/hooks.ts). expect(parsed.hooks).toBeDefined(); expect(parsed.hooks.sessionStart).toBeDefined(); + expect(parsed.hooks.agentStop).toBeDefined(); expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); + expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/audit.sh"); } else if (target === "augmentcode") { // AugmentCode mirrors Claude's PascalCase event names but emits commands // verbatim (AUGMENT_PROJECT_DIR is a runtime env var, not an inline prefix). @@ -128,6 +129,35 @@ describe("E2E: hooks", () => { } }); + it("should map canonical stop/subagentStop to copilot agentStop/subagentStop", async () => { + const testDir = getTestDir(); + + const hooksContent = JSON.stringify( + { + version: 1, + hooks: { + stop: [{ command: ".rulesync/hooks/agent-stop.sh" }], + subagentStop: [{ command: ".rulesync/hooks/subagent-stop.sh" }], + }, + }, + null, + 2, + ); + await writeFileContent(join(testDir, RULESYNC_HOOKS_RELATIVE_FILE_PATH), hooksContent); + + await runGenerate({ target: "copilot", features: "hooks" }); + + const generatedContent = await readFileContent( + join(testDir, ".github", "hooks", "copilot-hooks.json"), + ); + const parsed = JSON.parse(generatedContent); + // Canonical `stop` → `agentStop`, `subagentStop` → `subagentStop`. + expect(parsed.hooks.agentStop).toBeDefined(); + expect(JSON.stringify(parsed.hooks.agentStop)).toContain(".rulesync/hooks/agent-stop.sh"); + expect(parsed.hooks.subagentStop).toBeDefined(); + expect(JSON.stringify(parsed.hooks.subagentStop)).toContain(".rulesync/hooks/subagent-stop.sh"); + }); + it("should generate devin hooks", async () => { const testDir = getTestDir(); diff --git a/src/features/commands/copilot-command.test.ts b/src/features/commands/copilot-command.test.ts index 4191ab61c..8af25d84b 100644 --- a/src/features/commands/copilot-command.test.ts +++ b/src/features/commands/copilot-command.test.ts @@ -190,6 +190,8 @@ Body content`; expect(rulesyncCommand.getFrontmatter()).toEqual({ targets: ["*"], description: "Test description", + // The deprecated `mode` is migrated to `agent` in the copilot section. + copilot: { agent: "agent" }, }); expect(rulesyncCommand.getRelativeFilePath()).toBe("test-command.md"); }); @@ -640,9 +642,10 @@ Body content`; const rulesyncCommand = command.toRulesyncCommand(); const frontmatter = rulesyncCommand.getFrontmatter(); - // mode should not be in copilot section (it's excluded from extra fields) + // The deprecated `mode` is migrated to `agent`; other extra fields persist. expect(frontmatter.copilot).toEqual({ "custom-field": { nested: "value" }, + agent: "agent", }); }); @@ -678,7 +681,6 @@ Body content`; relativeDirPath: join(".github", "prompts"), relativeFilePath: "test.prompt.md", frontmatter: { - mode: "agent", description: "Test command", }, body: "Test body", @@ -689,6 +691,62 @@ Body content`; expect(frontmatter.copilot).toBeUndefined(); }); + + it("should migrate deprecated mode to agent in copilot section", () => { + const command = new CopilotCommand({ + outputRoot: testDir, + relativeDirPath: join(".github", "prompts"), + relativeFilePath: "test.prompt.md", + frontmatter: { + mode: "plan", + description: "Test command", + }, + body: "Test body", + validate: false, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + expect(rulesyncCommand.getFrontmatter().copilot).toEqual({ agent: "plan" }); + }); + + it("should prefer explicit agent over deprecated mode", () => { + const command = new CopilotCommand({ + outputRoot: testDir, + relativeDirPath: join(".github", "prompts"), + relativeFilePath: "test.prompt.md", + frontmatter: { + agent: "agent", + mode: "plan", + description: "Test command", + }, + body: "Test body", + validate: false, + }); + + const rulesyncCommand = command.toRulesyncCommand(); + expect(rulesyncCommand.getFrontmatter().copilot).toEqual({ agent: "agent" }); + }); + + it("should round-trip agent through fromRulesyncCommand and toRulesyncCommand", () => { + const original = new RulesyncCommand({ + outputRoot: testDir, + relativeDirPath: RULESYNC_COMMANDS_RELATIVE_DIR_PATH, + relativeFilePath: "agent-roundtrip.md", + frontmatter: { + targets: ["copilot"], + description: "Agent roundtrip", + copilot: { agent: "plan" }, + }, + body: "Body content", + fileContent: "", + }); + + const copilot = CopilotCommand.fromRulesyncCommand({ rulesyncCommand: original }); + expect(copilot.getFrontmatter().agent).toBe("plan"); + + const backToRulesync = copilot.toRulesyncCommand(); + expect(backToRulesync.getFrontmatter().copilot).toEqual({ agent: "plan" }); + }); }); describe("isTargetedByRulesyncCommand", () => { diff --git a/src/features/commands/copilot-command.ts b/src/features/commands/copilot-command.ts index f2093c08a..cd129634d 100644 --- a/src/features/commands/copilot-command.ts +++ b/src/features/commands/copilot-command.ts @@ -18,6 +18,12 @@ import { // looseObject preserves unknown keys during parsing (like passthrough in Zod 3) export const CopilotCommandFrontmatterSchema = z.looseObject({ + // `agent` is the current VS Code prompt-file field (values `ask` | `agent` | + // `plan` | a custom agent name). See + // https://code.visualstudio.com/docs/copilot/customization/prompt-files + agent: z.optional(z.string()), + // `mode` is the deprecated predecessor of `agent`; still accepted for + // backward compatibility and migrated to `agent` on import. mode: z.optional(z.string()), description: z.optional(z.string()), }); @@ -67,13 +73,23 @@ export class CopilotCommand extends ToolCommand { } toRulesyncCommand(): RulesyncCommand { - const { mode: _mode, description, ...restFields } = this.frontmatter; + const { mode, agent, description, ...restFields } = this.frontmatter; + + // Migrate the deprecated `mode` field to `agent`. If both are present, the + // explicit `agent` value wins. + const resolvedAgent = agent ?? mode; + + const copilotFields = { + ...restFields, + ...(resolvedAgent !== undefined && { agent: resolvedAgent }), + }; const rulesyncFrontmatter: RulesyncCommandFrontmatter = { targets: ["*"], description, - // Preserve extra fields in copilot section (excluding mode which is fixed) - ...(Object.keys(restFields).length > 0 && { copilot: restFields }), + // Preserve extra copilot-specific fields (including the normalized `agent`; + // the deprecated `mode` is dropped in favor of `agent`). + ...(Object.keys(copilotFields).length > 0 && { copilot: copilotFields }), }; // Strip .prompt.md extension and normalize to .md diff --git a/src/features/hooks/copilot-hooks.test.ts b/src/features/hooks/copilot-hooks.test.ts index 04e039b67..4891670b6 100644 --- a/src/features/hooks/copilot-hooks.test.ts +++ b/src/features/hooks/copilot-hooks.test.ts @@ -83,6 +83,35 @@ describe("CopilotHooks", () => { expect(parsed.hooks.stop).toBeUndefined(); }); + it("should map canonical stop/subagentStop to agentStop/subagentStop", async () => { + const config = { + version: 1, + hooks: { + stop: [{ command: ".rulesync/hooks/agent-stop.sh" }], + subagentStop: [{ command: ".rulesync/hooks/subagent-stop.sh" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const copilotHooks = await CopilotHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(copilotHooks.getFileContent()); + expect(parsed.hooks.agentStop).toBeDefined(); + expect(parsed.hooks.subagentStop).toBeDefined(); + // Canonical names must not leak into the generated Copilot file. + expect(parsed.hooks.stop).toBeUndefined(); + }); + it("should use bash field on non-Windows and timeoutSec instead of timeout", async () => { vi.spyOn(process, "platform", "get").mockReturnValue("linux"); const config = { @@ -453,6 +482,28 @@ describe("CopilotHooks", () => { expect(json.hooks.afterError?.[0]?.command).toBe("handle-error.sh"); }); + it("should map agentStop/subagentStop back to canonical stop/subagentStop", () => { + const copilotHooks = new CopilotHooks({ + outputRoot: testDir, + relativeDirPath: join(".github", "hooks"), + relativeFilePath: "copilot-hooks.json", + fileContent: JSON.stringify({ + version: 1, + hooks: { + agentStop: [{ type: "command", bash: "agent-stop.sh" }], + subagentStop: [{ type: "command", bash: "subagent-stop.sh" }], + }, + }), + validate: false, + }); + + const json = copilotHooks.toRulesyncHooks().getJson(); + expect(json.hooks.stop).toHaveLength(1); + expect(json.hooks.stop?.[0]?.command).toBe("agent-stop.sh"); + expect(json.hooks.subagentStop).toHaveLength(1); + expect(json.hooks.subagentStop?.[0]?.command).toBe("subagent-stop.sh"); + }); + it("should convert Copilot hooks with powershell-only to canonical format", () => { const copilotHooks = new CopilotHooks({ outputRoot: testDir, diff --git a/src/types/hooks.ts b/src/types/hooks.ts index fe3a50547..88ebacd3c 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -140,12 +140,16 @@ export const OPENCODE_HOOK_EVENTS: readonly HookEvent[] = [ export const KILO_HOOK_EVENTS: readonly HookEvent[] = OPENCODE_HOOK_EVENTS; /** - * Hook events supported by GitHub Copilot. + * Hook events supported by GitHub Copilot (cloud coding agent). * - * Reused by the Copilot CLI tool target (`copilotcli-hooks.ts`); both targets - * share the same six-event surface (`sessionStart`, `sessionEnd`, - * `userPromptSubmitted` ← `beforeSubmitPrompt`, `preToolUse`, `postToolUse`, - * `errorOccurred` ← `afterError`). + * GitHub now documents an eight-event surface for `.github/hooks/*.json`: + * `sessionStart`, `sessionEnd`, `userPromptSubmitted` ← `beforeSubmitPrompt`, + * `preToolUse`, `postToolUse`, `agentStop` ← `stop`, `subagentStop`, and + * `errorOccurred` ← `afterError`. `subagentStart` is intentionally absent: it is + * not part of the documented cloud-agent surface. + * + * @see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks + * @see https://docs.github.com/en/copilot/concepts/agents/hooks */ export const COPILOT_HOOK_EVENTS: readonly HookEvent[] = [ "sessionStart", @@ -153,6 +157,8 @@ export const COPILOT_HOOK_EVENTS: readonly HookEvent[] = [ "beforeSubmitPrompt", "preToolUse", "postToolUse", + "stop", + "subagentStop", "afterError", ]; @@ -508,6 +514,8 @@ export const CANONICAL_TO_COPILOT_EVENT_NAMES: Record = { beforeSubmitPrompt: "userPromptSubmitted", preToolUse: "preToolUse", postToolUse: "postToolUse", + stop: "agentStop", + subagentStop: "subagentStop", afterError: "errorOccurred", };