From 25d9437513a733ccad9eccabcbb6443e63a252d1 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 28 Apr 2026 14:19:44 +0900 Subject: [PATCH] feat(keyword-detector): add team mode keyword detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detects user invocations of team-mode work across English and Korean ('team mode', '팀 모드', '팀으로') and injects a concise English directive instructing the LLM to orchestrate via team_* tools (team_create -> team_task_create + team_send_message), forbidding delegate_task substitution and fallbacks. The Korean variants use a Hangul-syllable negative lookbehind (가-힣) so that '스팀으로 게임 켜줘' does not falsely match '팀으로' and '스팀모드' does not falsely match '팀모드'. Follows the existing folder pattern (mode/default.ts + mode/index.ts) shared by ultrawork/, search/, and analyze/. The hook orchestration in hook.ts handles the new keyword type generically through the shared KEYWORD_DETECTORS array, so existing guards (non-OMO agent skip, non-main session filter, system-reminder strip, code-block strip) all apply automatically. Adds 7 regression tests covering English/Korean trigger forms, the Hangul-prefix false-positive guard, the bare-'team' negative case, and non-main-session filtering. --- src/hooks/keyword-detector/AGENTS.md | 11 +- src/hooks/keyword-detector/constants.ts | 6 + src/hooks/keyword-detector/detector.ts | 4 +- src/hooks/keyword-detector/index.test.ts | 181 +++++++++++++++++++++ src/hooks/keyword-detector/team/default.ts | 17 ++ src/hooks/keyword-detector/team/index.ts | 1 + 6 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 src/hooks/keyword-detector/team/default.ts create mode 100644 src/hooks/keyword-detector/team/index.ts diff --git a/src/hooks/keyword-detector/AGENTS.md b/src/hooks/keyword-detector/AGENTS.md index e97813372..dc99464bb 100644 --- a/src/hooks/keyword-detector/AGENTS.md +++ b/src/hooks/keyword-detector/AGENTS.md @@ -4,7 +4,7 @@ ## OVERVIEW -8 files + 3 mode subdirs (~1665 LOC). Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze) and injects mode-specific system prompts. +Transform Tier hook on `messages.transform`. Scans first user message for mode keywords (ultrawork, search, analyze, team) and injects mode-specific system prompts. ## KEYWORDS @@ -13,6 +13,7 @@ | `ultrawork` / `ulw` | `/\b(ultrawork|ulw)\b/i` | Full orchestration mode — parallel agents, deep exploration, relentless execution | | Search mode | `SEARCH_PATTERN` (from `search/`) | Web/doc search focus prompt injection | | Analyze mode | `ANALYZE_PATTERN` (from `analyze/`) | Deep analysis mode prompt injection | +| Team mode | `TEAM_PATTERN` (from `team/`) | Forces orchestration via `team_*` tools when user invokes `team mode` / `팀 모드` / `팀으로`; instructs user to enable `team_mode.enabled` if tools are absent | ## STRUCTURE @@ -31,10 +32,12 @@ keyword-detector/ │ ├── index.ts │ ├── pattern.ts # SEARCH_PATTERN regex │ └── message.ts # SEARCH_MESSAGE -└── analyze/ +├── analyze/ +│ ├── index.ts +│ └── default.ts # ANALYZE_PATTERN + ANALYZE_MESSAGE +└── team/ ├── index.ts - ├── pattern.ts # ANALYZE_PATTERN regex - └── message.ts # ANALYZE_MESSAGE + └── default.ts # TEAM_PATTERN + TEAM_MESSAGE ``` ## DETECTION LOGIC diff --git a/src/hooks/keyword-detector/constants.ts b/src/hooks/keyword-detector/constants.ts index 5ae4568fe..a52837168 100644 --- a/src/hooks/keyword-detector/constants.ts +++ b/src/hooks/keyword-detector/constants.ts @@ -4,9 +4,11 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g export { isPlannerAgent, isNonOmoAgent, getUltraworkMessage } from "./ultrawork" export { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search" export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze" +export { TEAM_PATTERN, TEAM_MESSAGE } from "./team" import { getUltraworkMessage } from "./ultrawork" import { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search" +import { TEAM_PATTERN, TEAM_MESSAGE } from "./team" export type KeywordDetector = { pattern: RegExp @@ -41,4 +43,8 @@ SYNTHESIZE findings before proceeding. MANDATORY delegate_task params: ALWAYS include load_skills and run_in_background when calling delegate_task. Evaluate available skills before dispatch - pass task-appropriate skills when relevant, pass [] ONLY when no skill matches the task domain. Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`, }, + { + pattern: TEAM_PATTERN, + message: TEAM_MESSAGE, + }, ] diff --git a/src/hooks/keyword-detector/detector.ts b/src/hooks/keyword-detector/detector.ts index 0acde04f8..c149ea63d 100644 --- a/src/hooks/keyword-detector/detector.ts +++ b/src/hooks/keyword-detector/detector.ts @@ -5,7 +5,7 @@ import { } from "./constants" export interface DetectedKeyword { - type: "ultrawork" | "search" | "analyze" + type: "ultrawork" | "search" | "analyze" | "team" message: string } @@ -33,7 +33,7 @@ export function detectKeywords(text: string, agentName?: string, modelID?: strin export function detectKeywordsWithType(text: string, agentName?: string, modelID?: string): DetectedKeyword[] { const textWithoutCode = removeCodeBlocks(text) - const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"] + const types: Array = ["ultrawork", "search", "analyze", "team"] return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({ matches: pattern.test(textWithoutCode), type: types[index], diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index 95596c233..4fe241bd8 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -860,3 +860,184 @@ describe("keyword-detector non-OMO agent skipping", () => { expect(textPart!.text).not.toContain("[search-mode]") }) }) + +describe("keyword-detector team mode", () => { + let logCalls: Array<{ msg: string; data?: unknown }> + let logSpy: ReturnType + let getMainSessionSpy: ReturnType + + beforeEach(() => { + _resetForTesting() + logCalls = [] + logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { + logCalls.push({ msg, data }) + }) + }) + + afterEach(() => { + logSpy?.mockRestore() + getMainSessionSpy?.mockRestore() + _resetForTesting() + }) + + function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as unknown as PluginInput + } + + test("should inject team-mode message when user types 'team mode'", async () => { + // given - main session typing English 'team mode' + const collector = new ContextCollector() + const sessionID = "team-en-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "let's use team mode for this task" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode message should be prepended with team_* tool guidance + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("[team-mode]") + expect(textPart!.text).toContain("team_create") + expect(textPart!.text).toContain("team_task_create") + expect(textPart!.text).toContain("team_send_message") + expect(textPart!.text).toContain("NEVER substitute with delegate_task") + expect(textPart!.text).toContain("for this task") + }) + + test("should inject team-mode message when user types '팀 모드' (Korean with space)", async () => { + // given - main session typing Korean '팀 모드' + const collector = new ContextCollector() + const sessionID = "team-ko-spaced-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "이거 팀 모드로 해줘" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode message should be prepended + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("[team-mode]") + expect(textPart!.text).toContain("팀 모드로 해줘") + }) + + test("should inject team-mode message when user types '팀으로'", async () => { + // given - main session typing Korean '팀으로' + const collector = new ContextCollector() + const sessionID = "team-ko-eulo-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "팀으로 일하자" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode message should be prepended + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("[team-mode]") + expect(textPart!.text).toContain("팀으로 일하자") + }) + + test("should NOT trigger team-mode on '스팀으로' (false-positive guard)", async () => { + // given - text contains '팀으로' as substring of another Korean word ('스팀으로') + const collector = new ContextCollector() + const sessionID = "false-positive-eulo-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "스팀으로 게임 켜줘" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode should NOT be triggered, text unchanged + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("스팀으로 게임 켜줘") + expect(textPart!.text).not.toContain("[team-mode]") + }) + + test("should NOT trigger team-mode on '스팀모드' (Hangul-prefix false-positive guard)", async () => { + // given - text contains '팀모드' as substring of another Korean word ('스팀모드') + const collector = new ContextCollector() + const sessionID = "false-positive-mode-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "스팀모드 활성화" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode should NOT be triggered + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("스팀모드 활성화") + expect(textPart!.text).not.toContain("[team-mode]") + }) + + test("should NOT trigger team-mode on bare 'team' without 'mode'", async () => { + // given - text contains 'team' but not 'team mode' + const collector = new ContextCollector() + const sessionID = "bare-team-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "join the team and start working" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - team-mode should NOT be triggered + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[team-mode]") + }) + + test("should filter team-mode keyword in non-main session (only ultrawork allowed there)", async () => { + // given - main session set, different (subagent) session triggers team mode + const mainSessionID = "main-team-mode" + const subagentSessionID = "subagent-team-mode" + setMainSession(mainSessionID) + + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "team mode please" }], + } + + // when - subagent session triggers team mode keyword + await hook["chat.message"]({ sessionID: subagentSessionID }, output) + + // then - team-mode message should NOT be injected in subagent session + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("team mode please") + expect(textPart!.text).not.toContain("[team-mode]") + }) +}) diff --git a/src/hooks/keyword-detector/team/default.ts b/src/hooks/keyword-detector/team/default.ts new file mode 100644 index 000000000..59db8d462 --- /dev/null +++ b/src/hooks/keyword-detector/team/default.ts @@ -0,0 +1,17 @@ +/** + * Team mode keyword detector. + * + * Triggers when the user explicitly invokes team-mode work: + * - English: team mode, team-mode, team_mode, teammode (case-insensitive) + * - Korean: 팀 모드, 팀모드, 팀으로 + * + * The Korean variants use a negative lookbehind on Hangul syllables (가-힣) + * to prevent false positives like "스팀으로" matching "팀으로", or + * "스팀모드" matching "팀모드". + */ + +export const TEAM_PATTERN = + /\bteam[\s_-]?mode\b|(? team_task_create + team_send_message). NEVER substitute with delegate_task - it is not equivalent. If team_* tools are unavailable (team_mode disabled in config), instruct user to set team_mode.enabled=true and restart opencode.` diff --git a/src/hooks/keyword-detector/team/index.ts b/src/hooks/keyword-detector/team/index.ts new file mode 100644 index 000000000..0d4ceae6b --- /dev/null +++ b/src/hooks/keyword-detector/team/index.ts @@ -0,0 +1 @@ +export { TEAM_PATTERN, TEAM_MESSAGE } from "./default"