feat: add Ralph Loop self-referential development loop (#337)

* feat(config): add RalphLoopConfigSchema and hook name

- Add ralph-loop to HookNameSchema enum
- Add RalphLoopConfigSchema with enabled, default_max_iterations, state_dir
- Add ralph_loop field to OhMyOpenCodeConfigSchema
- Export RalphLoopConfig type

* feat(ralph-loop): add hook directory structure with constants and types

- Add constants.ts with HOOK_NAME, DEFAULT_STATE_FILE, COMPLETION_TAG_PATTERN
- Add types.ts with RalphLoopState and RalphLoopOptions interfaces
- Export RalphLoopConfig from config/index.ts

* feat(ralph-loop): add storage module for markdown state file management

- Implement readState/writeState/clearState/incrementIteration
- Use YAML frontmatter format for state persistence
- Support custom state file paths via config

* feat(ralph-loop): implement main hook with session.idle handler

- Add createRalphLoopHook factory with event handler
- Implement startLoop, cancelLoop, getState API
- Detect completion promise in transcript
- Auto-continue with iteration tracking
- Handle max iterations limit
- Show toast notifications for status updates
- Support session recovery and cleanup

* test(ralph-loop): add comprehensive BDD-style tests

- Add 17 test cases covering storage, hook lifecycle, iteration
- Test completion detection, cancellation, recovery, session cleanup
- Fix storage.ts to handle YAML value parsing correctly
- Use BDD #given/#when/#then comments per project convention

* feat(builtin-commands): add ralph-loop and cancel-ralph commands

* feat(ralph-loop): register hook in main plugin

* docs: add Ralph Loop feature to all README files

* chore: regenerate JSON schema with ralph-loop config

* feat(ralph-loop): change state file path from .opencode to .sisyphus

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode

* feat(ralph-loop): integrate ralph-loop and cancel-ralph command handlers into plugin hooks

- Add chat.message hook to detect and start ralph-loop or cancel-ralph templates
- Add slashcommand hook to handle /ralph-loop and /cancel-ralph commands
- Support custom --max-iterations and --completion-promise options

🤖 Generated with assistance of https://github.com/code-yeongyu/oh-my-opencode

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
This commit is contained in:
YeonGyu-Kim
2025-12-30 17:41:03 +09:00
committed by GitHub
parent c401113537
commit 0f0f49b823
17 changed files with 974 additions and 6 deletions

View File

@@ -22,3 +22,4 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";

View File

@@ -0,0 +1,5 @@
export const HOOK_NAME = "ralph-loop"
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
export const DEFAULT_MAX_ITERATIONS = 100
export const DEFAULT_COMPLETION_PROMISE = "DONE"

View File

@@ -0,0 +1,388 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { createRalphLoopHook } from "./index"
import { readState, writeState, clearState } from "./storage"
import type { RalphLoopState } from "./types"
describe("ralph-loop", () => {
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
let promptCalls: Array<{ sessionID: string; text: string }>
let toastCalls: Array<{ title: string; message: string; variant: string }>
function createMockPluginInput() {
return {
client: {
session: {
prompt: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
promptCalls.push({
sessionID: opts.path.id,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
variant: opts.body.variant,
})
return {}
},
},
},
directory: TEST_DIR,
} as Parameters<typeof createRalphLoopHook>[0]
}
beforeEach(() => {
promptCalls = []
toastCalls = []
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
}
clearState(TEST_DIR)
})
afterEach(() => {
clearState(TEST_DIR)
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true })
}
})
describe("storage", () => {
test("should write and read state correctly", () => {
// #given - a state object
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",
}
// #when - write and read state
const writeSuccess = writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// #then - state should match
expect(writeSuccess).toBe(true)
expect(readResult).not.toBeNull()
expect(readResult?.active).toBe(true)
expect(readResult?.iteration).toBe(1)
expect(readResult?.max_iterations).toBe(50)
expect(readResult?.completion_promise).toBe("DONE")
expect(readResult?.prompt).toBe("Build a REST API")
expect(readResult?.session_id).toBe("test-session-123")
})
test("should return null for non-existent state", () => {
// #given - no state file exists
// #when - read state
const result = readState(TEST_DIR)
// #then - should return null
expect(result).toBeNull()
})
test("should clear state correctly", () => {
// #given - existing state
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Test prompt",
}
writeState(TEST_DIR, state)
// #when - clear state
const clearSuccess = clearState(TEST_DIR)
const readResult = readState(TEST_DIR)
// #then - state should be cleared
expect(clearSuccess).toBe(true)
expect(readResult).toBeNull()
})
test("should handle multiline prompts", () => {
// #given - state with multiline prompt
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations: 10,
completion_promise: "FINISHED",
started_at: "2025-12-30T02:00:00Z",
prompt: "Build a feature\nwith multiple lines\nand requirements",
}
// #when - write and read
writeState(TEST_DIR, state)
const readResult = readState(TEST_DIR)
// #then - multiline prompt preserved
expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
})
})
describe("hook", () => {
test("should start loop and write state", () => {
// #given - hook instance
const hook = createRalphLoopHook(createMockPluginInput())
// #when - start loop
const success = hook.startLoop("session-123", "Build something", {
maxIterations: 25,
completionPromise: "FINISHED",
})
// #then - state should be written
expect(success).toBe(true)
const state = hook.getState()
expect(state?.active).toBe(true)
expect(state?.iteration).toBe(1)
expect(state?.max_iterations).toBe(25)
expect(state?.completion_promise).toBe("FINISHED")
expect(state?.prompt).toBe("Build something")
expect(state?.session_id).toBe("session-123")
})
test("should inject continuation when loop active and no completion detected", async () => {
// #given - active loop state
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - continuation should be injected
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].sessionID).toBe("session-123")
expect(promptCalls[0].text).toContain("RALPH LOOP")
expect(promptCalls[0].text).toContain("Build a feature")
expect(promptCalls[0].text).toContain("2/10")
// #then - iteration should be incremented
const state = hook.getState()
expect(state?.iteration).toBe(2)
})
test("should stop loop when max iterations reached", async () => {
// #given - loop at max iteration
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
const state = hook.getState()!
state.iteration = 2
writeState(TEST_DIR, state)
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - no continuation injected
expect(promptCalls.length).toBe(0)
// #then - warning toast shown
expect(toastCalls.length).toBe(1)
expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
expect(toastCalls[0].variant).toBe("warning")
// #then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should cancel loop via cancelLoop", () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - cancel loop
const success = hook.cancelLoop("session-123")
// #then - loop cancelled
expect(success).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should not cancel loop for different session", () => {
// #given - active loop for session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - try to cancel for different session
const success = hook.cancelLoop("session-456")
// #then - cancel should fail
expect(success).toBe(false)
expect(hook.getState()).not.toBeNull()
})
test("should skip injection during recovery", async () => {
// #given - active loop and session in recovery
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
await hook.event({
event: {
type: "session.error",
properties: { sessionID: "session-123", error: new Error("test") },
},
})
// #when - session goes idle immediately
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should clear state on session deletion", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - session deleted
await hook.event({
event: {
type: "session.deleted",
properties: { info: { id: "session-123" } },
},
})
// #then - state should be cleared
expect(hook.getState()).toBeNull()
})
test("should not inject for different session than loop owner", async () => {
// #given - loop owned by session-123
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Test task")
// #when - different session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-456" },
},
})
// #then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should use default config values", () => {
// #given - hook with config
const hook = createRalphLoopHook(createMockPluginInput(), {
config: {
enabled: true,
default_max_iterations: 200,
},
})
// #when - start loop without options
hook.startLoop("session-123", "Test task")
// #then - should use config defaults
const state = hook.getState()
expect(state?.max_iterations).toBe(200)
})
test("should not inject when no loop is active", async () => {
// #given - no active loop
const hook = createRalphLoopHook(createMockPluginInput())
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - no continuation injected
expect(promptCalls.length).toBe(0)
})
test("should detect completion promise and stop loop", async () => {
// #given - active loop with transcript containing completion
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
// #when - session goes idle with transcript
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123", transcriptPath },
},
})
// #then - loop completed, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should handle multiple iterations correctly", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
// #when - multiple idle events
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - iteration incremented correctly
expect(hook.getState()?.iteration).toBe(3)
expect(promptCalls.length).toBe(2)
})
test("should include prompt and promise in continuation message", async () => {
// #given - loop with specific prompt and promise
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Create a calculator app", {
completionPromise: "CALCULATOR_DONE",
maxIterations: 10,
})
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation includes original task and promise
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
})
})

View File

@@ -0,0 +1,272 @@
import { existsSync, readFileSync } from "node:fs"
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import { readState, writeState, clearState, incrementIteration } from "./storage"
import {
HOOK_NAME,
DEFAULT_MAX_ITERATIONS,
DEFAULT_COMPLETION_PROMISE,
} from "./constants"
import type { RalphLoopState, RalphLoopOptions } from "./types"
export * from "./types"
export * from "./constants"
export { readState, writeState, clearState, incrementIteration } from "./storage"
interface SessionState {
isRecovering?: boolean
}
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
Your previous attempt did not output the completion promise. Continue working on the task.
IMPORTANT:
- Review your progress so far
- Continue from where you left off
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
- Do not stop until the task is truly done
Original task:
{{PROMPT}}`
export interface RalphLoopHook {
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
startLoop: (
sessionID: string,
prompt: string,
options?: { maxIterations?: number; completionPromise?: string }
) => boolean
cancelLoop: (sessionID: string) => boolean
getState: () => RalphLoopState | null
}
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
): RalphLoopHook {
const sessions = new Map<string, SessionState>()
const config = options?.config
const stateDir = config?.state_dir
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = {}
sessions.set(sessionID, state)
}
return state
}
function detectCompletionPromise(
transcriptPath: string | undefined,
promise: string
): boolean {
if (!transcriptPath) return false
try {
if (!existsSync(transcriptPath)) return false
const content = readFileSync(transcriptPath, "utf-8")
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
return pattern.test(content)
} catch {
return false
}
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
const startLoop = (
sessionID: string,
prompt: string,
loopOptions?: { maxIterations?: number; completionPromise?: string }
): boolean => {
const state: RalphLoopState = {
active: true,
iteration: 1,
max_iterations:
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
started_at: new Date().toISOString(),
prompt,
session_id: sessionID,
}
const success = writeState(ctx.directory, state, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop started`, {
sessionID,
maxIterations: state.max_iterations,
completionPromise: state.completion_promise,
})
}
return success
}
const cancelLoop = (sessionID: string): boolean => {
const state = readState(ctx.directory, stateDir)
if (!state || state.session_id !== sessionID) {
return false
}
const success = clearState(ctx.directory, stateDir)
if (success) {
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
}
return success
}
const getState = (): RalphLoopState | null => {
return readState(ctx.directory, stateDir)
}
const event = async ({
event,
}: {
event: { type: string; properties?: unknown }
}): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const sessionState = getSessionState(sessionID)
if (sessionState.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
const state = readState(ctx.directory, stateDir)
if (!state || !state.active) {
return
}
if (state.session_id && state.session_id !== sessionID) {
return
}
const transcriptPath = props?.transcriptPath as string | undefined
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
})
clearState(ctx.directory, stateDir)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop Complete!",
message: `Task completed after ${state.iteration} iteration(s)`,
variant: "success",
duration: 5000,
},
})
.catch(() => {})
return
}
if (state.iteration >= state.max_iterations) {
log(`[${HOOK_NAME}] Max iterations reached`, {
sessionID,
iteration: state.iteration,
max: state.max_iterations,
})
clearState(ctx.directory, stateDir)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop Stopped",
message: `Max iterations (${state.max_iterations}) reached without completion`,
variant: "warning",
duration: 5000,
},
})
.catch(() => {})
return
}
const newState = incrementIteration(ctx.directory, stateDir)
if (!newState) {
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
return
}
log(`[${HOOK_NAME}] Continuing loop`, {
sessionID,
iteration: newState.iteration,
max: newState.max_iterations,
})
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
.replace("{{MAX}}", String(newState.max_iterations))
.replace("{{PROMISE}}", newState.completion_promise)
.replace("{{PROMPT}}", newState.prompt)
await ctx.client.tui
.showToast({
body: {
title: "Ralph Loop",
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
variant: "info",
duration: 2000,
},
})
.catch(() => {})
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [{ type: "text", text: continuationPrompt }],
},
query: { directory: ctx.directory },
})
} catch (err) {
log(`[${HOOK_NAME}] Failed to inject continuation`, {
sessionID,
error: String(err),
})
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionInfo.id) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
}
sessions.delete(sessionInfo.id)
}
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true
setTimeout(() => {
sessionState.isRecovering = false
}, 5000)
}
}
}
return {
event,
startLoop,
cancelLoop,
getState,
}
}

View File

@@ -0,0 +1,113 @@
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
import { dirname, join } from "node:path"
import { parseFrontmatter } from "../../shared/frontmatter"
import type { RalphLoopState } from "./types"
import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants"
export function getStateFilePath(directory: string, customPath?: string): string {
return customPath
? join(directory, customPath)
: join(directory, DEFAULT_STATE_FILE)
}
export function readState(directory: string, customPath?: string): RalphLoopState | null {
const filePath = getStateFilePath(directory, customPath)
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
const { data, body } = parseFrontmatter<Record<string, unknown>>(content)
const active = data.active
const iteration = data.iteration
if (active === undefined || iteration === undefined) {
return null
}
const isActive = active === true || active === "true"
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
if (isNaN(iterationNum)) {
return null
}
const stripQuotes = (val: unknown): string => {
const str = String(val ?? "")
return str.replace(/^["']|["']$/g, "")
}
return {
active: isActive,
iteration: iterationNum,
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
prompt: body.trim(),
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
}
} catch {
return null
}
}
export function writeState(
directory: string,
state: RalphLoopState,
customPath?: string
): boolean {
const filePath = getStateFilePath(directory, customPath)
try {
const dir = dirname(filePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\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}---
${state.prompt}
`
writeFileSync(filePath, content, "utf-8")
return true
} catch {
return false
}
}
export function clearState(directory: string, customPath?: string): boolean {
const filePath = getStateFilePath(directory, customPath)
try {
if (existsSync(filePath)) {
unlinkSync(filePath)
}
return true
} catch {
return false
}
}
export function incrementIteration(
directory: string,
customPath?: string
): RalphLoopState | null {
const state = readState(directory, customPath)
if (!state) return null
state.iteration += 1
if (writeState(directory, state, customPath)) {
return state
}
return null
}

View File

@@ -0,0 +1,15 @@
import type { RalphLoopConfig } from "../../config"
export interface RalphLoopState {
active: boolean
iteration: number
max_iterations: number
completion_promise: string
started_at: string
prompt: string
session_id?: string
}
export interface RalphLoopOptions {
config?: RalphLoopConfig
}