feat(keyword-detector): add team mode keyword detection
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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<DetectedKeyword["type"]> = ["ultrawork", "search", "analyze", "team"]
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
|
||||
@@ -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<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
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]")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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|(?<![가-힣])(?:팀\s*모드|팀으로)/i
|
||||
|
||||
export const TEAM_MESSAGE = `[team-mode]
|
||||
Team mode reference detected. If user wants team-mode work, MUST orchestrate via team_* tools (team_create -> 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.`
|
||||
@@ -0,0 +1 @@
|
||||
export { TEAM_PATTERN, TEAM_MESSAGE } from "./default"
|
||||
Reference in New Issue
Block a user