diff --git a/src/e2e/e2e-hooks.spec.ts b/src/e2e/e2e-hooks.spec.ts index 5151cc07d..f6a862c74 100644 --- a/src/e2e/e2e-hooks.spec.ts +++ b/src/e2e/e2e-hooks.spec.ts @@ -466,11 +466,13 @@ describe("E2E: hooks (global mode)", () => { expect(parsed.hooks.sessionStart).toBeDefined(); expect(JSON.stringify(parsed.hooks)).toContain(".rulesync/hooks/session-start.sh"); } else if (target === "junie") { - // Junie CLI only supports the `sessionStart` event (PascalCase - // SessionStart), so `stop` (audit.sh) is dropped during generation. + // Junie CLI supports SessionStart, UserPromptSubmit, Stop, and SessionEnd + // (PascalCase), so both `sessionStart` and `stop` (audit.sh) survive. const parsed = JSON.parse(generatedContent); expect(parsed.hooks.SessionStart).toBeDefined(); + expect(parsed.hooks.Stop).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 === "antigravity-ide" || target === "antigravity-cli") { // Antigravity nests the event map under a generated `rulesync` hook name // and supports preToolUse/postToolUse/preModelInvocation/ diff --git a/src/features/hooks/junie-hooks.test.ts b/src/features/hooks/junie-hooks.test.ts index 45f61c41d..576007008 100644 --- a/src/features/hooks/junie-hooks.test.ts +++ b/src/features/hooks/junie-hooks.test.ts @@ -36,7 +36,7 @@ describe("JunieHooks", () => { }); describe("fromRulesyncHooks", () => { - it("should emit hooks.SessionStart from a canonical sessionStart hook and drop unsupported events", async () => { + it("should emit all supported Junie events from canonical inputs and drop unsupported events", async () => { await ensureDir(join(testDir, ".junie")); await writeFileContent(join(testDir, ".junie", "config.json"), JSON.stringify({})); @@ -44,7 +44,11 @@ describe("JunieHooks", () => { version: 1, hooks: { sessionStart: [{ type: "command", command: ".rulesync/hooks/session-start.sh" }], - stop: [{ command: ".rulesync/hooks/audit.sh" }], + beforeSubmitPrompt: [{ type: "command", command: ".rulesync/hooks/prompt.sh" }], + stop: [{ type: "command", command: ".rulesync/hooks/audit.sh" }], + sessionEnd: [{ type: "command", command: ".rulesync/hooks/session-end.sh" }], + // preToolUse is not a Junie-supported event and must be dropped. + preToolUse: [{ type: "command", command: ".rulesync/hooks/pre-tool.sh" }], }, }; const rulesyncHooks = new RulesyncHooks({ @@ -66,8 +70,55 @@ describe("JunieHooks", () => { expect(JSON.stringify(parsed.hooks.SessionStart)).toContain( ".rulesync/hooks/session-start.sh", ); - // Junie supports only sessionStart, so `stop` is dropped. - expect(parsed.hooks.Stop).toBeUndefined(); + // UserPromptSubmit, Stop, and SessionEnd are now supported. + expect(parsed.hooks.UserPromptSubmit).toBeDefined(); + expect(JSON.stringify(parsed.hooks.UserPromptSubmit)).toContain(".rulesync/hooks/prompt.sh"); + expect(parsed.hooks.Stop).toBeDefined(); + expect(JSON.stringify(parsed.hooks.Stop)).toContain(".rulesync/hooks/audit.sh"); + expect(parsed.hooks.SessionEnd).toBeDefined(); + expect(JSON.stringify(parsed.hooks.SessionEnd)).toContain(".rulesync/hooks/session-end.sh"); + // preToolUse is not supported by Junie, so it is dropped. + expect(parsed.hooks.PreToolUse).toBeUndefined(); + }); + + it("should drop matchers on matcher-less events (UserPromptSubmit, Stop) but keep them on SessionStart", async () => { + await ensureDir(join(testDir, ".junie")); + await writeFileContent(join(testDir, ".junie", "config.json"), JSON.stringify({})); + + const config = { + version: 1, + hooks: { + sessionStart: [ + { type: "command", command: ".rulesync/hooks/session-start.sh", matcher: "startup" }, + ], + beforeSubmitPrompt: [ + { type: "command", command: ".rulesync/hooks/prompt.sh", matcher: "ignored" }, + ], + stop: [{ type: "command", command: ".rulesync/hooks/audit.sh", matcher: "ignored" }], + }, + }; + const rulesyncHooks = new RulesyncHooks({ + outputRoot: testDir, + relativeDirPath: RULESYNC_RELATIVE_DIR_PATH, + relativeFilePath: "hooks.json", + fileContent: JSON.stringify(config), + validate: false, + }); + + const junieHooks = await JunieHooks.fromRulesyncHooks({ + outputRoot: testDir, + rulesyncHooks, + validate: false, + }); + + const parsed = JSON.parse(junieHooks.getFileContent()); + // SessionStart supports matchers, so it is preserved. + expect(parsed.hooks.SessionStart[0].matcher).toBe("startup"); + // UserPromptSubmit and Stop are matcher-less; the matcher key is stripped. + expect(parsed.hooks.UserPromptSubmit[0].matcher).toBeUndefined(); + expect(parsed.hooks.UserPromptSubmit[0].hooks).toBeDefined(); + expect(parsed.hooks.Stop[0].matcher).toBeUndefined(); + expect(parsed.hooks.Stop[0].hooks).toBeDefined(); }); it("should preserve a pre-existing unrelated key in config.json", async () => { diff --git a/src/features/hooks/junie-hooks.ts b/src/features/hooks/junie-hooks.ts index 6bc4b3d62..d040e5a98 100644 --- a/src/features/hooks/junie-hooks.ts +++ b/src/features/hooks/junie-hooks.ts @@ -22,6 +22,12 @@ import { type ToolHooksSettablePaths, } from "./tool-hooks.js"; +// Junie CLI applies matchers only to `SessionStart` / `SessionEnd`; +// `UserPromptSubmit` (beforeSubmitPrompt) and `Stop` (stop) are matcher-less and +// always run, so any matcher on those events is dropped to match the upstream +// capability. See https://junie.jetbrains.com/docs/junie-cli-hooks.html +const JUNIE_NO_MATCHER_EVENTS: ReadonlySet = new Set(["beforeSubmitPrompt", "stop"]); + const JUNIE_CONVERTER_CONFIG: ToolHooksConverterConfig = { supportedEvents: JUNIE_HOOK_EVENTS, canonicalToToolEventNames: CANONICAL_TO_JUNIE_EVENT_NAMES, @@ -30,6 +36,7 @@ const JUNIE_CONVERTER_CONFIG: ToolHooksConverterConfig = { // Junie CLI hooks only support `type: "command"`; drop any `prompt`-type // hooks so generation matches the declared capability. supportedHookTypes: new Set(["command"]), + noMatcherEvents: JUNIE_NO_MATCHER_EVENTS, }; export class JunieHooks extends ToolHooks { diff --git a/src/types/hooks.test.ts b/src/types/hooks.test.ts index 7f638051d..5300d6cce 100644 --- a/src/types/hooks.test.ts +++ b/src/types/hooks.test.ts @@ -6,6 +6,7 @@ import { CANONICAL_TO_CURSOR_EVENT_NAMES, CANONICAL_TO_DEEPAGENTS_EVENT_NAMES, CANONICAL_TO_FACTORYDROID_EVENT_NAMES, + CANONICAL_TO_JUNIE_EVENT_NAMES, CANONICAL_TO_OPENCODE_EVENT_NAMES, CLAUDE_HOOK_EVENTS, CODEXCLI_HOOK_EVENTS, @@ -13,6 +14,8 @@ import { CURSOR_HOOK_EVENTS, DEEPAGENTS_HOOK_EVENTS, FACTORYDROID_HOOK_EVENTS, + JUNIE_HOOK_EVENTS, + JUNIE_TO_CANONICAL_EVENT_NAMES, OPENCODE_HOOK_EVENTS, } from "./hooks.js"; @@ -52,6 +55,32 @@ describe("Event map completeness", () => { expect(CANONICAL_TO_CODEXCLI_EVENT_NAMES).toHaveProperty(event); } }); + + it("every JUNIE_HOOK_EVENTS entry should exist in CANONICAL_TO_JUNIE_EVENT_NAMES", () => { + for (const event of JUNIE_HOOK_EVENTS) { + expect(CANONICAL_TO_JUNIE_EVENT_NAMES).toHaveProperty(event); + } + }); +}); + +describe("Junie CLI event naming", () => { + it("should map canonical event names to documented Junie PascalCase names", () => { + // Verified against https://junie.jetbrains.com/docs/junie-cli-hooks.html + expect(CANONICAL_TO_JUNIE_EVENT_NAMES.sessionStart).toBe("SessionStart"); + expect(CANONICAL_TO_JUNIE_EVENT_NAMES.beforeSubmitPrompt).toBe("UserPromptSubmit"); + expect(CANONICAL_TO_JUNIE_EVENT_NAMES.stop).toBe("Stop"); + expect(CANONICAL_TO_JUNIE_EVENT_NAMES.sessionEnd).toBe("SessionEnd"); + }); + + it("should support the SessionStart, UserPromptSubmit, Stop, and SessionEnd events", () => { + expect(JUNIE_HOOK_EVENTS).toEqual(["sessionStart", "beforeSubmitPrompt", "stop", "sessionEnd"]); + }); + + it("should round-trip every Junie event name back to canonical", () => { + for (const [canonical, junie] of Object.entries(CANONICAL_TO_JUNIE_EVENT_NAMES)) { + expect(JUNIE_TO_CANONICAL_EVENT_NAMES[junie]).toBe(canonical); + } + }); }); describe("Codex CLI event naming", () => { diff --git a/src/types/hooks.ts b/src/types/hooks.ts index 88ebacd3c..3e774d21f 100644 --- a/src/types/hooks.ts +++ b/src/types/hooks.ts @@ -325,12 +325,21 @@ export const AUGMENTCODE_HOOK_EVENTS: readonly HookEvent[] = [ /** * Hook events supported by JetBrains Junie CLI. - * Junie CLI currently exposes only the SessionStart lifecycle event - * (matchers `startup` / `resume`), defined under the `"hooks"` key of - * `~/.junie/config.json`. Project-local hooks are ignored for safety. + * + * Junie CLI exposes four lifecycle events under the `"hooks"` key of + * `~/.junie/config.json`: `SessionStart`, `UserPromptSubmit`, `Stop`, and + * `SessionEnd`. Matchers apply only to `SessionStart` / `SessionEnd` + * (e.g. `startup` / `resume`); `UserPromptSubmit` and `Stop` are + * matcher-less. Only `type: "command"` hooks are supported. Project-local + * hooks are ignored for safety. * @see https://junie.jetbrains.com/docs/junie-cli-hooks.html */ -export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = ["sessionStart"]; +export const JUNIE_HOOK_EVENTS: readonly HookEvent[] = [ + "sessionStart", + "beforeSubmitPrompt", + "stop", + "sessionEnd", +]; const hooksRecordSchema = z.record(z.string(), z.array(HookDefinitionSchema)); @@ -675,6 +684,9 @@ export const KIRO_TO_CANONICAL_EVENT_NAMES: Record = Object.from */ export const CANONICAL_TO_JUNIE_EVENT_NAMES: Record = { sessionStart: "SessionStart", + beforeSubmitPrompt: "UserPromptSubmit", + stop: "Stop", + sessionEnd: "SessionEnd", }; /**