diff --git a/src/config/schema/keyword-detector.ts b/src/config/schema/keyword-detector.ts index 01003828f..ce46a3967 100644 --- a/src/config/schema/keyword-detector.ts +++ b/src/config/schema/keyword-detector.ts @@ -1,6 +1,6 @@ import { z } from "zod" -export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team", "hyperplan"]) +export const KeywordTypeSchema = z.enum(["ultrawork", "search", "analyze", "team", "hyperplan", "hyperplan-ultrawork"]) export type KeywordType = z.infer export const KeywordDetectorConfigSchema = z.object({ diff --git a/src/hooks/keyword-detector/constants.ts b/src/hooks/keyword-detector/constants.ts index b6bef2037..c0831b166 100644 --- a/src/hooks/keyword-detector/constants.ts +++ b/src/hooks/keyword-detector/constants.ts @@ -7,26 +7,45 @@ export { ANALYZE_PATTERN, ANALYZE_MESSAGE } from "./analyze" export { TEAM_PATTERN, TEAM_MESSAGE } from "./team" export { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan" +import type { KeywordType } from "../../config/schema/keyword-detector" import { getUltraworkMessage } from "./ultrawork" import { SEARCH_PATTERN, SEARCH_MESSAGE } from "./search" import { TEAM_PATTERN, TEAM_MESSAGE } from "./team" import { HYPERPLAN_PATTERN, HYPERPLAN_MESSAGE } from "./hyperplan" +// Hyperplan-ultrawork combo: strict adjacency, both word orders +export const HYPERPLAN_ULTRAWORK_PATTERN = + /\b(?:hpp|hyperplan)\s+(?:ulw|ultrawork)\b|\b(?:ulw|ultrawork)\s+(?:hpp|hyperplan)\b/i + +const HYPERPLAN_ULTRAWORK_BANNER = ` +**MANDATORY**: Say "HYPERPLAN ULTRAWORK MODE ENABLED!" exactly once as your first response. Do NOT say the standalone "ULTRAWORK MODE ENABLED!" or "HYPERPLAN MODE ENABLED!" banners. + +Apply the ultrawork protocol below as your execution framework. You MUST ALSO load the hyperplan skill immediately via \`skill(name="hyperplan")\` and follow its full adversarial workflow — do NOT improvise, do NOT skip rounds, do NOT write the plan yourself. +` + +export function getHyperplanUltraworkMessage(agentName?: string, modelID?: string): string { + return `${HYPERPLAN_ULTRAWORK_BANNER}\n\n${getUltraworkMessage(agentName, modelID)}` +} + export type KeywordDetector = { + type: KeywordType pattern: RegExp message: string | ((agentName?: string, modelID?: string) => string) } export const KEYWORD_DETECTORS: KeywordDetector[] = [ { + type: "ultrawork", pattern: /\b(ultrawork|ulw)\b/i, message: getUltraworkMessage, }, { + type: "search", pattern: SEARCH_PATTERN, message: SEARCH_MESSAGE, }, { + type: "analyze", pattern: /\b(analyze|analyse|investigate|examine|research|study|deep[\s-]?dive|inspect|audit|evaluate|assess|review|diagnose|scrutinize|dissect|debug|comprehend|interpret|breakdown|understand)\b|why\s+is|how\s+does|how\s+to|분석|조사|파악|연구|검토|진단|이해|설명|원인|이유|뜯어봐|따져봐|평가|해석|디버깅|디버그|어떻게|왜|살펴|分析|調査|解析|検討|研究|診断|理解|説明|検証|精査|究明|デバッグ|なぜ|どう|仕組み|调查|检查|剖析|深入|诊断|解释|调试|为什么|原理|搞清楚|弄明白|phân tích|điều tra|nghiên cứu|kiểm tra|xem xét|chẩn đoán|giải thích|tìm hiểu|gỡ lỗi|tại sao/i, message: `[analyze-mode] @@ -46,11 +65,18 @@ MANDATORY delegate_task params: ALWAYS include load_skills=[] and run_in_backgro Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`, }, { + type: "team", pattern: TEAM_PATTERN, message: TEAM_MESSAGE, }, { + type: "hyperplan", pattern: HYPERPLAN_PATTERN, message: HYPERPLAN_MESSAGE, }, + { + type: "hyperplan-ultrawork", + pattern: HYPERPLAN_ULTRAWORK_PATTERN, + message: getHyperplanUltraworkMessage, + }, ] diff --git a/src/hooks/keyword-detector/detector.ts b/src/hooks/keyword-detector/detector.ts index 4f593fd8d..852d04f63 100644 --- a/src/hooks/keyword-detector/detector.ts +++ b/src/hooks/keyword-detector/detector.ts @@ -40,11 +40,14 @@ export function detectKeywordsWithType( disabledKeywords?: ReadonlyArray, ): DetectedKeyword[] { const textWithoutCode = removeCodeBlocks(text) - const types: Array = ["ultrawork", "search", "analyze", "team", "hyperplan"] const disabled = new Set(disabledKeywords ?? []) - return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({ + // Intersection rule: combo requires BOTH base keywords enabled + if (disabled.has("ultrawork") || disabled.has("hyperplan")) { + disabled.add("hyperplan-ultrawork") + } + return KEYWORD_DETECTORS.map(({ type, pattern, message }) => ({ matches: pattern.test(textWithoutCode), - type: types[index], + type, message: resolveMessage(message, agentName, modelID), })) .filter((result) => result.matches && !disabled.has(result.type)) diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 7df46fe69..85408e84c 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -1,5 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector" +import type { DetectedKeyword } from "./detector" import { detectKeywordsWithType, extractPromptText } from "./detector" import { isPlannerAgent, isNonOmoAgent } from "./constants" import { log } from "../../shared" @@ -15,6 +16,12 @@ import { import type { ContextCollector } from "../../features/context-injector" import type { RalphLoopHook } from "../ralph-loop" +function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[] { + const hasCombo = detected.some((k) => k.type === "hyperplan-ultrawork") + if (!hasCombo) return detected + return detected.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan") +} + export function createKeywordDetectorHook( ctx: PluginInput, _collector?: ContextCollector, @@ -63,10 +70,13 @@ export function createKeywordDetectorHook( const cleanText = removeSystemReminders(promptText) const modelID = input.model?.modelID let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID, disabledKeywords) + detectedKeywords = suppressComboStandalones(detectedKeywords) if (isPlannerAgent(currentAgent)) { const preFilterCount = detectedKeywords.length - detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan") + detectedKeywords = detectedKeywords.filter( + (k) => k.type !== "ultrawork" && k.type !== "hyperplan" && k.type !== "hyperplan-ultrawork" + ) if (preFilterCount > detectedKeywords.length) { log(`[keyword-detector] Filtered ultrawork/hyperplan keywords for planner agent`, { sessionID: input.sessionID, agent: currentAgent }) } @@ -86,7 +96,9 @@ export function createKeywordDetectorHook( const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID if (isNonMainSession) { - detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork") + detectedKeywords = detectedKeywords.filter( + (k) => k.type === "ultrawork" || k.type === "hyperplan-ultrawork" + ) if (detectedKeywords.length === 0) { log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, { sessionID: input.sessionID, @@ -149,6 +161,21 @@ export function createKeywordDetectorHook( ) } + const hasHyperplanUltrawork = detectedKeywords.some((k) => k.type === "hyperplan-ultrawork") + if (hasHyperplanUltrawork) { + log(`[keyword-detector] Hyperplan Ultrawork mode activated`, { sessionID: input.sessionID }) + ctx.client.tui + .showToast({ + body: { + title: "Hyperplan Ultrawork Mode Activated", + message: "Ultrawork execution with adversarial hyperplan workflow.", + variant: "success" as const, + duration: 3000, + }, + }) + .catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })) + } + const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined) if (textPartIndex === -1) { log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID }) diff --git a/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts new file mode 100644 index 000000000..37b938171 --- /dev/null +++ b/src/hooks/keyword-detector/hyperplan-ultrawork.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" +import type { PluginInput } from "@opencode-ai/plugin" +import { createKeywordDetectorHook } from "./index" +import { setMainSession, _resetForTesting } from "../../features/claude-code-session-state" +import * as sharedModule from "../../shared" +import * as sessionState from "../../features/claude-code-session-state" + +describe("keyword-detector hyperplan-ultrawork combo", () => { + let logSpy: ReturnType + let getMainSessionSpy: ReturnType + + beforeEach(() => { + _resetForTesting() + logSpy = spyOn(sharedModule, "log").mockImplementation(() => {}) + }) + + afterEach(() => { + logSpy?.mockRestore() + getMainSessionSpy?.mockRestore() + _resetForTesting() + }) + + function createMockPluginInput(options: { toastCalls?: string[] } = {}) { + const toastCalls = options.toastCalls ?? [] + return { + client: { + tui: { + showToast: async (opts: { body: { title: string } }) => { + toastCalls.push(opts.body.title) + }, + }, + }, + } as unknown as PluginInput + } + + test("should inject combo message when user types 'hpp ulw' (forward order)", async () => { + // given - main session with adjacent forward-order combo keywords + const sessionID = "combo-forward-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw refactor the auth module" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - combo banner and embedded ultrawork content both present + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("refactor the auth module") + }) + + test("should inject combo message when user types 'ulw hpp' (reverse order)", async () => { + // given - main session with adjacent reverse-order combo keywords + const sessionID = "combo-reverse-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ulw hpp ship this feature" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - combo fires identically regardless of word order + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("ship this feature") + }) + + test("should NOT trigger combo on non-adjacent 'hpp do ulw' but fire both standalones instead", async () => { + // given - keywords separated by another word block adjacency requirement + const sessionID = "combo-non-adjacent-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp do ulw stuff" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - combo absent, both standalone banners injected separately + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("") + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + }) + + test("should suppress standalone messages when combo fires (only ONE banner injected)", async () => { + // given - combo keywords that would also match standalone patterns + const sessionID = "combo-suppress-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw build" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - only combo banner present, standalone hyperplan suppressed, ultrawork content appears once via embed + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + expect(textPart!.text).not.toContain("") + const ultraworkMatches = textPart!.text!.match(//g) ?? [] + expect(ultraworkMatches).toHaveLength(1) + }) + + test("should fire combo toast and suppress standalone toasts", async () => { + // given - combo keywords with toast tracking + const sessionID = "combo-toast-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw do it" }], + } + + // when - combo fires + await hook["chat.message"]({ sessionID }, output) + + // then - only combo toast title is shown, standalone toasts suppressed + expect(toastCalls).toContain("Hyperplan Ultrawork Mode Activated") + expect(toastCalls).not.toContain("Ultrawork Mode Activated") + expect(toastCalls).not.toContain("Hyperplan Mode Activated") + }) + + test("should disable combo only when disabled_keywords includes 'hyperplan-ultrawork' (standalones still fire)", async () => { + // given - combo keyword disabled but standalones remain enabled + const sessionID = "combo-disabled-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook( + createMockPluginInput(), + undefined, + undefined, + { disabled_keywords: ["hyperplan-ultrawork"] }, + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw work it" }], + } + + // when - keyword detection runs with combo disabled + await hook["chat.message"]({ sessionID }, output) + + // then - combo absent, both individual standalones still match and inject + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("") + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + }) + + test("should block combo via intersection rule when disabled_keywords includes 'ultrawork'", async () => { + // given - ultrawork standalone disabled, intersection rule cascades to combo + const sessionID = "combo-intersection-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook( + createMockPluginInput({ toastCalls }), + undefined, + undefined, + { disabled_keywords: ["ultrawork"] }, + ) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw plan stuff" }], + } + + // when - combo would match but is blocked via intersection + await hook["chat.message"]({ sessionID }, output) + + // then - no combo, no ultrawork content leaks; standalone hyperplan still fires + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("") + expect(textPart!.text).not.toContain("") + expect(textPart!.text).toContain("") + expect(toastCalls).not.toContain("Hyperplan Ultrawork Mode Activated") + expect(toastCalls).not.toContain("Ultrawork Mode Activated") + }) + + test("should allow combo in non-main session (passes through like standalone ultrawork)", async () => { + // given - main session set, different (subagent) session triggers combo + const mainSessionID = "main-combo" + const subagentSessionID = "subagent-combo" + setMainSession(mainSessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw run this" }], + } + + // when - subagent session triggers combo + await hook["chat.message"]({ sessionID: subagentSessionID }, output) + + // then - combo banner reaches non-main session (whitelisted alongside standalone ultrawork) + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("run this") + }) + + test("should filter combo when agent is prometheus (planner)", async () => { + // given - planner agent receives a combo prompt + const sessionID = "combo-prometheus-session" + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw plan stuff" }], + } + + // when - planner-agent path filters all execution-mode keywords + await hook["chat.message"]({ sessionID, agent: "prometheus" }, output) + + // then - text untouched: combo, ultrawork, and hyperplan all filtered for planner + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("hpp ulw plan stuff") + expect(textPart!.text).not.toContain("") + expect(textPart!.text).not.toContain("") + expect(textPart!.text).not.toContain("") + }) + + test("should reuse ultrawork variant: combo with GPT model embeds GPT ultrawork content", async () => { + // given - GPT-5.4 model selects the GPT ultrawork variant inside the combo banner + const sessionID = "combo-gpt-variant-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hpp ulw build feature" }], + } + + // when - combo fires with GPT model resolved + await hook["chat.message"]( + { sessionID, agent: "sisyphus", model: { providerID: "openai", modelID: "gpt-5.4" } }, + output, + ) + + // then - combo banner present and GPT-variant ultrawork content embedded (output_verbosity_spec is GPT-only) + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + expect(textPart!.text).toContain("") + }) +}) diff --git a/src/hooks/keyword-detector/hyperplan.test.ts b/src/hooks/keyword-detector/hyperplan.test.ts index 5110d6498..12e293d81 100644 --- a/src/hooks/keyword-detector/hyperplan.test.ts +++ b/src/hooks/keyword-detector/hyperplan.test.ts @@ -220,25 +220,4 @@ describe("keyword-detector hyperplan keyword", () => { expect(textPart!.text).not.toContain('skill(name="hyperplan")') expect(textPart!.text).toContain("hpp build the feature") }) - - test("should inject hyperplan AND ultrawork together when both keywords present", async () => { - // given - main session typing both keywords in the same message - const sessionID = "hyperplan-combo-session" - getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) - const hook = createKeywordDetectorHook(createMockPluginInput()) - const output = { - message: {} as Record, - parts: [{ type: "text", text: "ultrawork hyperplan ship this feature" }], - } - - // when - both keywords trigger - await hook["chat.message"]({ sessionID }, output) - - // then - both messages should be present - const textPart = output.parts.find(p => p.type === "text") - expect(textPart).toBeDefined() - expect(textPart!.text).toContain("") - expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS") - expect(textPart!.text).toContain("ship this feature") - }) })