diff --git a/src/features/builtin-commands/commands.ts b/src/features/builtin-commands/commands.ts index f7649e07..f489c198 100644 --- a/src/features/builtin-commands/commands.ts +++ b/src/features/builtin-commands/commands.ts @@ -17,17 +17,28 @@ $ARGUMENTS `, argumentHint: "[--create-new] [--max-depth=N]", }, - "ralph-loop": { - description: "(builtin) Start self-referential development loop until completion", - template: ` + "ralph-loop": { + description: "(builtin) Start self-referential development loop until completion", + template: ` ${RALPH_LOOP_TEMPLATE} $ARGUMENTS `, - argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]', - }, + argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]', + }, + "ulw-loop": { + description: "(builtin) Start ultrawork loop - continues until completion with ultrawork mode", + template: ` +${RALPH_LOOP_TEMPLATE} + + + +$ARGUMENTS +`, + argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]', + }, "cancel-ralph": { description: "(builtin) Cancel active Ralph Loop", template: ` diff --git a/src/features/builtin-commands/types.ts b/src/features/builtin-commands/types.ts index 4df23f53..c626092c 100644 --- a/src/features/builtin-commands/types.ts +++ b/src/features/builtin-commands/types.ts @@ -1,6 +1,6 @@ import type { CommandDefinition } from "../claude-code-command-loader" -export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "refactor" | "start-work" +export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "ulw-loop" | "refactor" | "start-work" export interface BuiltinCommandConfig { disabled_commands?: BuiltinCommandName[] diff --git a/src/hooks/auto-slash-command/constants.ts b/src/hooks/auto-slash-command/constants.ts index 250b8917..de2a49a7 100644 --- a/src/hooks/auto-slash-command/constants.ts +++ b/src/hooks/auto-slash-command/constants.ts @@ -8,4 +8,5 @@ export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/ export const EXCLUDED_COMMANDS = new Set([ "ralph-loop", "cancel-ralph", + "ulw-loop", ]) diff --git a/src/hooks/ralph-loop/index.test.ts b/src/hooks/ralph-loop/index.test.ts index b0f15452..3a6a77ed 100644 --- a/src/hooks/ralph-loop/index.test.ts +++ b/src/hooks/ralph-loop/index.test.ts @@ -92,6 +92,27 @@ describe("ralph-loop", () => { expect(readResult?.session_id).toBe("test-session-123") }) + test("should handle ultrawork field", () => { + // #given - a state object with ultrawork enabled + const state: RalphLoopState = { + active: true, + iteration: 1, + max_iterations: 50, + completion_promise: "DONE", + started_at: "2025-12-30T01:00:00Z", + prompt: "Build a REST API", + session_id: "test-session-123", + ultrawork: true, + } + + // #when - write and read state + writeState(TEST_DIR, state) + const readResult = readState(TEST_DIR) + + // #then - ultrawork field should be preserved + expect(readResult?.ultrawork).toBe(true) + }) + test("should return null for non-existent state", () => { // #given - no state file exists // #when - read state @@ -164,6 +185,30 @@ describe("ralph-loop", () => { expect(state?.session_id).toBe("session-123") }) + test("should accept ultrawork option in startLoop", () => { + // #given - hook instance + const hook = createRalphLoopHook(createMockPluginInput()) + + // #when - start loop with ultrawork + hook.startLoop("session-123", "Build something", { ultrawork: true }) + + // #then - state should have ultrawork=true + const state = hook.getState() + expect(state?.ultrawork).toBe(true) + }) + + test("should handle missing ultrawork option in startLoop", () => { + // #given - hook instance + const hook = createRalphLoopHook(createMockPluginInput()) + + // #when - start loop without ultrawork + hook.startLoop("session-123", "Build something") + + // #then - state should have ultrawork=undefined + const state = hook.getState() + expect(state?.ultrawork).toBeUndefined() + }) + test("should inject continuation when loop active and no completion detected", async () => { // #given - active loop state const hook = createRalphLoopHook(createMockPluginInput()) @@ -672,7 +717,10 @@ describe("ralph-loop", () => { // #when - session goes idle await hook.event({ - event: { type: "session.idle", properties: { sessionID: "session-123" } }, + event: { + type: "session.idle", + properties: { sessionID: "session-123" }, + }, }) // #then - should complete via transcript (API not called when transcript succeeds) @@ -681,6 +729,70 @@ describe("ralph-loop", () => { // API should NOT be called since transcript found completion expect(messagesCalls.length).toBe(0) }) + + test("should show ultrawork completion toast", async () => { + // #given - hook with ultrawork mode and completion in transcript + const transcriptPath = join(TEST_DIR, "transcript.jsonl") + const hook = createRalphLoopHook(createMockPluginInput(), { + getTranscriptPath: () => transcriptPath, + }) + writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" })) + hook.startLoop("test-id", "Build API", { ultrawork: true }) + + // #when - idle event triggered + await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } }) + + // #then - ultrawork toast shown + const completionToast = toastCalls.find(t => t.title === "ULTRAWORK LOOP COMPLETE!") + expect(completionToast).toBeDefined() + expect(completionToast!.message).toMatch(/JUST ULW ULW!/) + }) + + test("should show regular completion toast when ultrawork disabled", async () => { + // #given - hook without ultrawork + const transcriptPath = join(TEST_DIR, "transcript.jsonl") + const hook = createRalphLoopHook(createMockPluginInput(), { + getTranscriptPath: () => transcriptPath, + }) + writeFileSync(transcriptPath, JSON.stringify({ content: "DONE" })) + hook.startLoop("test-id", "Build API") + + // #when - idle event triggered + await hook.event({ event: { type: "session.idle", properties: { sessionID: "test-id" } } }) + + // #then - regular toast shown + expect(toastCalls.some(t => t.title === "Ralph Loop Complete!")).toBe(true) + }) + + test("should prepend ultrawork to continuation prompt when ultrawork=true", async () => { + // #given - hook with ultrawork mode enabled + const hook = createRalphLoopHook(createMockPluginInput()) + hook.startLoop("session-123", "Build API", { ultrawork: true }) + + // #when - session goes idle (continuation triggered) + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // #then - prompt should start with "ultrawork " + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].text).toMatch(/^ultrawork /) + }) + + test("should NOT prepend ultrawork to continuation prompt when ultrawork=false", async () => { + // #given - hook without ultrawork mode + const hook = createRalphLoopHook(createMockPluginInput()) + hook.startLoop("session-123", "Build API") + + // #when - session goes idle (continuation triggered) + await hook.event({ + event: { type: "session.idle", properties: { sessionID: "session-123" } }, + }) + + // #then - prompt should NOT start with "ultrawork " + expect(promptCalls.length).toBe(1) + expect(promptCalls[0].text).not.toMatch(/^ultrawork /) + }) }) describe("API timeout protection", () => { diff --git a/src/hooks/ralph-loop/index.ts b/src/hooks/ralph-loop/index.ts index 17783dee..9f27f201 100644 --- a/src/hooks/ralph-loop/index.ts +++ b/src/hooks/ralph-loop/index.ts @@ -61,7 +61,7 @@ export interface RalphLoopHook { startLoop: ( sessionID: string, prompt: string, - options?: { maxIterations?: number; completionPromise?: string } + options?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } ) => boolean cancelLoop: (sessionID: string) => boolean getState: () => RalphLoopState | null @@ -150,7 +150,7 @@ export function createRalphLoopHook( const startLoop = ( sessionID: string, prompt: string, - loopOptions?: { maxIterations?: number; completionPromise?: string } + loopOptions?: { maxIterations?: number; completionPromise?: string; ultrawork?: boolean } ): boolean => { const state: RalphLoopState = { active: true, @@ -158,6 +158,7 @@ export function createRalphLoopHook( max_iterations: loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS, completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE, + ultrawork: loopOptions?.ultrawork, started_at: new Date().toISOString(), prompt, session_id: sessionID, @@ -251,11 +252,18 @@ export function createRalphLoopHook( }) clearState(ctx.directory, stateDir) + const title = state.ultrawork + ? "ULTRAWORK LOOP COMPLETE!" + : "Ralph Loop Complete!" + const message = state.ultrawork + ? `JUST ULW ULW! Task completed after ${state.iteration} iteration(s)` + : `Task completed after ${state.iteration} iteration(s)` + await ctx.client.tui .showToast({ body: { - title: "Ralph Loop Complete!", - message: `Task completed after ${state.iteration} iteration(s)`, + title, + message, variant: "success", duration: 5000, }, @@ -304,6 +312,10 @@ export function createRalphLoopHook( .replace("{{PROMISE}}", newState.completion_promise) .replace("{{PROMPT}}", newState.prompt) + const finalPrompt = newState.ultrawork + ? `ultrawork ${continuationPrompt}` + : continuationPrompt + await ctx.client.tui .showToast({ body: { @@ -346,7 +358,7 @@ export function createRalphLoopHook( body: { ...(agent !== undefined ? { agent } : {}), ...(model !== undefined ? { model } : {}), - parts: [{ type: "text", text: continuationPrompt }], + parts: [{ type: "text", text: finalPrompt }], }, query: { directory: ctx.directory }, }) diff --git a/src/hooks/ralph-loop/storage.ts b/src/hooks/ralph-loop/storage.ts index 86d47257..0929443b 100644 --- a/src/hooks/ralph-loop/storage.ts +++ b/src/hooks/ralph-loop/storage.ts @@ -48,6 +48,7 @@ export function readState(directory: string, customPath?: string): RalphLoopStat started_at: stripQuotes(data.started_at) || new Date().toISOString(), prompt: body.trim(), session_id: data.session_id ? stripQuotes(data.session_id) : undefined, + ultrawork: data.ultrawork === true || data.ultrawork === "true" ? true : undefined, } } catch { return null @@ -68,13 +69,14 @@ export function writeState( } const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : "" + const ultraworkLine = state.ultrawork !== undefined ? `ultrawork: ${state.ultrawork}\n` : "" const content = `--- active: ${state.active} iteration: ${state.iteration} max_iterations: ${state.max_iterations} completion_promise: "${state.completion_promise}" started_at: "${state.started_at}" -${sessionIdLine}--- +${sessionIdLine}${ultraworkLine}--- ${state.prompt} ` diff --git a/src/hooks/ralph-loop/types.ts b/src/hooks/ralph-loop/types.ts index b8d0c9a4..0c6c9d1d 100644 --- a/src/hooks/ralph-loop/types.ts +++ b/src/hooks/ralph-loop/types.ts @@ -8,6 +8,7 @@ export interface RalphLoopState { started_at: string prompt: string session_id?: string + ultrawork?: boolean } export interface RalphLoopOptions { diff --git a/src/index.ts b/src/index.ts index f4f76b26..755edf33 100644 --- a/src/index.ts +++ b/src/index.ts @@ -525,9 +525,30 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { : undefined, completionPromise: promiseMatch?.[1], }); - } else if (command === "cancel-ralph" && sessionID) { - ralphLoop.cancelLoop(sessionID); - } + } else if (command === "cancel-ralph" && sessionID) { + ralphLoop.cancelLoop(sessionID); + } else if (command === "ulw-loop" && sessionID) { + const rawArgs = + args?.command?.replace(/^\/?(ulw-loop)\s*/i, "") || ""; + const taskMatch = rawArgs.match(/^["'](.+?)["']/); + const prompt = + taskMatch?.[1] || + rawArgs.split(/\s+--/)[0]?.trim() || + "Complete the task as instructed"; + + const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i); + const promiseMatch = rawArgs.match( + /--completion-promise=["']?([^"'\s]+)["']?/i + ); + + ralphLoop.startLoop(sessionID, prompt, { + ultrawork: true, + maxIterations: maxIterMatch + ? parseInt(maxIterMatch[1], 10) + : undefined, + completionPromise: promiseMatch?.[1], + }); + } } },