Add /ulw-loop command for ultrawork mode loop (#867)
* feat(ralph-loop): add ultrawork field to RalphLoopState * feat(ralph-loop): persist ultrawork field in storage * feat(ralph-loop): accept ultrawork option in startLoop Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(ralph-loop): prepend ultrawork keyword when mode active Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai> * feat(ralph-loop): custom toast for ultrawork mode * feat(ralph-loop): add /ulw-loop command for ultrawork mode * fix(ralph-loop): add non-null assertion for type safety * fix(ralph-loop): mirror argument parsing in ulw-loop handler - Parse quoted prompts and strip flags from task text - Support --max-iterations and --completion-promise options - Add default prompt for empty input - Fixes behavior inconsistency with /ralph-loop --------- Co-authored-by: justsisyphus <sisyphus-dev-ai@users.noreply.github.com> Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
36b665ed89
commit
d13e8411f0
@ -17,17 +17,28 @@ $ARGUMENTS
|
||||
</user-request>`,
|
||||
argumentHint: "[--create-new] [--max-depth=N]",
|
||||
},
|
||||
"ralph-loop": {
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>
|
||||
"ralph-loop": {
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>
|
||||
${RALPH_LOOP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-task>
|
||||
$ARGUMENTS
|
||||
</user-task>`,
|
||||
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: `<command-instruction>
|
||||
${RALPH_LOOP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-task>
|
||||
$ARGUMENTS
|
||||
</user-task>`,
|
||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
||||
},
|
||||
"cancel-ralph": {
|
||||
description: "(builtin) Cancel active Ralph Loop",
|
||||
template: `<command-instruction>
|
||||
|
||||
@ -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[]
|
||||
|
||||
@ -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",
|
||||
])
|
||||
|
||||
@ -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: "<promise>DONE</promise>" }))
|
||||
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: "<promise>DONE</promise>" }))
|
||||
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", () => {
|
||||
|
||||
@ -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 },
|
||||
})
|
||||
|
||||
@ -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}
|
||||
`
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ export interface RalphLoopState {
|
||||
started_at: string
|
||||
prompt: string
|
||||
session_id?: string
|
||||
ultrawork?: boolean
|
||||
}
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
|
||||
27
src/index.ts
27
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],
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user