* 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>
114 lines
3.0 KiB
TypeScript
114 lines
3.0 KiB
TypeScript
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
|
|
}
|