From 5c67482038dea2f32ab57a9236faf14989c02d52 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 29 Apr 2026 17:36:18 +0900 Subject: [PATCH] feat(keyword-detector): add hyperplan-ultrawork combo + tuple refactor Adjacent "hpp ulw" or "ulw hpp" (8 form combinations: short/long, both orders) triggers a fused mode that suppresses the standalone ultrawork and hyperplan banners and toasts in favor of one combo banner. The combo banner explicitly preserves hyperplan's mandatory adversarial workflow contract (do NOT improvise, do NOT skip rounds) instead of silently downgrading it. Suppression runs as a named pipeline step (suppressComboStandalones) immediately after detection and before all consumers (planner filter, session filters, toasts, message injection), so standalone toast checks naturally see the already-suppressed list. Combo is allowed in non-main sessions like ultrawork, filtered for planner agents like both standalones, and blocked in subagent sessions via the existing gate. disabled_keywords uses the intersection rule: disabling either "ultrawork" or "hyperplan" also disables the combo, so no flavored content leaks via the combo embedding when either base keyword is disabled. Includes a same-PR refactor of KEYWORD_DETECTORS from {pattern, message} to {type, pattern, message} tuple shape, dropping the parallel hardcoded types array in detector.ts that previously coupled type assignment to registry index. Future detector additions can no longer silently corrupt DetectedKeyword.type via reorder or insertion. 10 behavioral contract tests in hyperplan-ultrawork.test.ts cover both trigger orders, non-adjacent rejection, suppression of injection and toast, intersection-rule disable behavior, session/agent policy, and ultrawork variant routing through the combo. The pre-existing combined "ultrawork hyperplan" assertion in hyperplan.test.ts is removed in favor of the new file. Plan distilled from a hyperplan adversarial review (5 members, 3 rounds: skeptic, validator, researcher, architect, creative). --- src/config/schema/keyword-detector.ts | 2 +- src/hooks/keyword-detector/constants.ts | 26 ++ src/hooks/keyword-detector/detector.ts | 9 +- src/hooks/keyword-detector/hook.ts | 31 ++- .../hyperplan-ultrawork.test.ts | 261 ++++++++++++++++++ src/hooks/keyword-detector/hyperplan.test.ts | 21 -- 6 files changed, 323 insertions(+), 27 deletions(-) create mode 100644 src/hooks/keyword-detector/hyperplan-ultrawork.test.ts 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 d710edbfe..9f92d3f12 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_background 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") - }) })