@john-ezra/openralph 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.dockerignore +25 -0
- package/AGENTS.md +148 -0
- package/CHANGELOG.md +16 -0
- package/LICENSE +21 -0
- package/PROMPT_build.md +48 -0
- package/PROMPT_plan.md +29 -0
- package/README.md +247 -0
- package/bin/openralph +5 -0
- package/bun.lock +85 -0
- package/container/Dockerfile +58 -0
- package/container/bin/chrome-devtools-mcp-wrapper +57 -0
- package/package.json +50 -0
- package/src/args.ts +236 -0
- package/src/artifacts.ts +183 -0
- package/src/cli.ts +125 -0
- package/src/design.ts +24 -0
- package/src/docker.ts +403 -0
- package/src/exec.ts +103 -0
- package/src/git.ts +62 -0
- package/src/launcher.ts +235 -0
- package/src/loop.ts +339 -0
- package/src/plugin.ts +56 -0
- package/src/release-check.ts +143 -0
- package/src/sentinels.ts +23 -0
- package/src/tags.ts +22 -0
- package/src/trust.ts +116 -0
- package/src/tui.ts +436 -0
- package/tsconfig.json +14 -0
package/src/git.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { runCommand, type CommandResult } from "./exec.ts"
|
|
2
|
+
|
|
3
|
+
const DEFENSIVE_GIT_CONFIG = [
|
|
4
|
+
["core.hooksPath", "/dev/null"],
|
|
5
|
+
["core.fsmonitor", "false"],
|
|
6
|
+
["core.pager", "cat"],
|
|
7
|
+
] as const
|
|
8
|
+
|
|
9
|
+
export interface GitContext {
|
|
10
|
+
root: string
|
|
11
|
+
branch: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function requireGitContext(cwd: string): Promise<GitContext> {
|
|
15
|
+
const root = await runGit(["rev-parse", "--show-toplevel"], cwd)
|
|
16
|
+
const branchResult = await runGitCommand(["branch", "--show-current"], root.stdout.trim())
|
|
17
|
+
const branch = branchResult.exitCode === 0 && branchResult.stdout.trim() ? branchResult.stdout.trim() : "HEAD"
|
|
18
|
+
return { root: root.stdout.trim(), branch }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function buildDefensiveGitArgs(args: string[]): string[] {
|
|
22
|
+
return ["--no-pager", ...DEFENSIVE_GIT_CONFIG.flatMap(([key, value]) => ["-c", `${key}=${value}`]), ...args]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runGitCommand(args: string[], cwd: string): Promise<CommandResult> {
|
|
26
|
+
return runCommand("git", buildDefensiveGitArgs(args), cwd)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function getHead(cwd: string): Promise<string | undefined> {
|
|
30
|
+
const result = await runGitCommand(["rev-parse", "HEAD"], cwd)
|
|
31
|
+
if (result.exitCode !== 0) return undefined
|
|
32
|
+
return result.stdout.trim()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function isWorktreeClean(cwd: string): Promise<boolean> {
|
|
36
|
+
const result = await runGit(["status", "--porcelain"], cwd)
|
|
37
|
+
return result.stdout.trim() === ""
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function tagExists(cwd: string, tagName: string): Promise<boolean> {
|
|
41
|
+
const result = await runGitCommand(["rev-parse", "-q", "--verify", `refs/tags/${tagName}`], cwd)
|
|
42
|
+
return result.exitCode === 0
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function createLightweightTag(cwd: string, tagName: string): Promise<void> {
|
|
46
|
+
if (await tagExists(cwd, tagName)) throw new Error(`tag already exists: ${tagName}`)
|
|
47
|
+
await runGit(["tag", tagName], cwd)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function pushCurrentBranch(cwd: string, branch: string): Promise<void> {
|
|
51
|
+
if (!branch || branch === "HEAD") throw new Error("cannot push from detached HEAD")
|
|
52
|
+
await runGit(["push", "origin", branch], cwd)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function runGit(args: string[], cwd: string) {
|
|
56
|
+
const result = await runGitCommand(args, cwd)
|
|
57
|
+
if (result.exitCode !== 0) {
|
|
58
|
+
const detail = (result.stderr || result.stdout).trim()
|
|
59
|
+
throw new Error(detail || `git ${args.join(" ")} failed`)
|
|
60
|
+
}
|
|
61
|
+
return result
|
|
62
|
+
}
|
package/src/launcher.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { formatLoopArgsForReplay, parseLoopArgs, resolveDockerOptions, validateOptions, type LoopPhase, type OpenRalphOptions, type ParsedLoopArgs } from "./args.ts"
|
|
2
|
+
import { CONTAINER_WORKSPACE, runDockerLoop } from "./docker.ts"
|
|
3
|
+
import { commandExists as defaultCommandExists, type CommandOutputEvent, type CommandResult } from "./exec.ts"
|
|
4
|
+
import { requireGitContext } from "./git.ts"
|
|
5
|
+
import { formatSummary, runLoop, type LoopSummary } from "./loop.ts"
|
|
6
|
+
import { attestDockerEnvironment, hasDockerMarker, type TrustDeps } from "./trust.ts"
|
|
7
|
+
|
|
8
|
+
export type LauncherMode = "docker-host-launch" | "host-explicit" | "host-config-default" | "container-attested"
|
|
9
|
+
|
|
10
|
+
export interface RunLauncherInput {
|
|
11
|
+
phase: LoopPhase
|
|
12
|
+
rawArgs: string
|
|
13
|
+
cwd: string
|
|
14
|
+
options: OpenRalphOptions
|
|
15
|
+
streamOutput?: boolean
|
|
16
|
+
captureOutput?: boolean
|
|
17
|
+
onOutput?: (event: CommandOutputEvent) => void
|
|
18
|
+
signal?: AbortSignal
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LauncherResult {
|
|
22
|
+
phase: LoopPhase
|
|
23
|
+
mode: LauncherMode
|
|
24
|
+
status: LoopSummary["status"]
|
|
25
|
+
summary: string
|
|
26
|
+
outputTail?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface RunLauncherDeps {
|
|
30
|
+
env?: NodeJS.ProcessEnv
|
|
31
|
+
requireGitContext?: typeof requireGitContext
|
|
32
|
+
runDockerLoop?: typeof runDockerLoop
|
|
33
|
+
runLoop?: typeof runLoop
|
|
34
|
+
commandExists?: typeof defaultCommandExists
|
|
35
|
+
trust?: TrustDeps
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResolveLauncherModeInput {
|
|
39
|
+
parsed: ParsedLoopArgs
|
|
40
|
+
options: OpenRalphOptions
|
|
41
|
+
env?: NodeJS.ProcessEnv
|
|
42
|
+
trust?: TrustDeps
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runOpenRalphLauncher(input: RunLauncherInput, deps: RunLauncherDeps = {}): Promise<LauncherResult> {
|
|
46
|
+
const parsed = parseLoopArgs(input.phase, input.rawArgs)
|
|
47
|
+
const mode = await resolveLauncherMode({ parsed, options: input.options, env: deps.env, trust: deps.trust })
|
|
48
|
+
|
|
49
|
+
if (mode === "docker-host-launch") {
|
|
50
|
+
if (input.phase === "build" && parsed.push) {
|
|
51
|
+
throw new Error("OpenRalph Build --push is not supported in Docker mode. Run without --push, review the local commits, then push from the host.")
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
await preflightCommands(mode, deps.commandExists ?? defaultCommandExists)
|
|
56
|
+
|
|
57
|
+
if (mode === "docker-host-launch") {
|
|
58
|
+
const git = await (deps.requireGitContext ?? requireGitContext)(input.cwd)
|
|
59
|
+
const result = await (deps.runDockerLoop ?? runDockerLoop)({
|
|
60
|
+
phase: input.phase,
|
|
61
|
+
rawArgs: input.rawArgs,
|
|
62
|
+
projectRoot: git.root,
|
|
63
|
+
options: input.options,
|
|
64
|
+
streamOutput: input.streamOutput,
|
|
65
|
+
captureOutput: input.captureOutput,
|
|
66
|
+
onOutput: input.onOutput,
|
|
67
|
+
signal: input.signal,
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
if (result.exitCode !== 0) {
|
|
71
|
+
throw new Error(formatDockerFailure(input.phase, parsed, result))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const output = commandOutput(result)
|
|
75
|
+
const displayOutput = translateContainerPaths(output, git.root) ?? ""
|
|
76
|
+
const innerSummary = translateContainerPaths(extractOpenRalphSummary(input.phase, output), git.root)
|
|
77
|
+
if (innerSummary?.startsWith(`OpenRalph ${input.phase} failed:`)) {
|
|
78
|
+
throw new Error(formatDockerInnerFailure(input.phase, innerSummary))
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
phase: input.phase,
|
|
83
|
+
mode,
|
|
84
|
+
status: "complete",
|
|
85
|
+
summary: formatDockerSuccess(input.phase, result, git.root),
|
|
86
|
+
outputTail: displayOutput ? tailLines(displayOutput, 80) : undefined,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const loopSummary = await (deps.runLoop ?? runLoop)({
|
|
91
|
+
phase: input.phase,
|
|
92
|
+
rawArgs: input.rawArgs,
|
|
93
|
+
cwd: input.cwd,
|
|
94
|
+
options: input.options,
|
|
95
|
+
executionMode: mode,
|
|
96
|
+
streamOutput: input.streamOutput,
|
|
97
|
+
onOutput: input.onOutput,
|
|
98
|
+
signal: input.signal,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
phase: input.phase,
|
|
103
|
+
mode,
|
|
104
|
+
status: loopSummary.status,
|
|
105
|
+
summary: formatSummary(loopSummary),
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function resolveLauncherMode(input: ResolveLauncherModeInput): Promise<LauncherMode> {
|
|
110
|
+
const env = input.env ?? process.env
|
|
111
|
+
const docker = resolveDockerOptions(input.options)
|
|
112
|
+
|
|
113
|
+
if (await hasDockerMarker(env, input.trust)) {
|
|
114
|
+
if (!(await attestDockerEnvironment(env, input.trust))) {
|
|
115
|
+
throw new Error("OpenRalph Docker attestation failed. Refusing to run a loop with untrusted Docker markers or token state.")
|
|
116
|
+
}
|
|
117
|
+
return "container-attested"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (docker.enabled && !input.parsed.noDocker) return "docker-host-launch"
|
|
121
|
+
if (input.parsed.noDocker) return "host-explicit"
|
|
122
|
+
return "host-config-default"
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readOptionsFromEnv(env: NodeJS.ProcessEnv = process.env): OpenRalphOptions {
|
|
126
|
+
const raw = env.OPENRALPH_OPTIONS_JSON
|
|
127
|
+
if (!raw) return {}
|
|
128
|
+
|
|
129
|
+
let parsed: unknown
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(raw)
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(`OPENRALPH_OPTIONS_JSON must be valid JSON: ${formatError(error)}`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return validateOptions(parsed)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function formatDockerSuccess(phase: LoopPhase, result: CommandResult, projectRoot?: string): string {
|
|
140
|
+
const output = translateContainerPaths(commandOutput(result), projectRoot) ?? ""
|
|
141
|
+
const summary = extractOpenRalphSummary(phase, output)
|
|
142
|
+
const lines = [`OpenRalph ${phase} Docker execution completed.`]
|
|
143
|
+
|
|
144
|
+
if (summary) {
|
|
145
|
+
lines.push("", summary)
|
|
146
|
+
} else if (output) {
|
|
147
|
+
lines.push("", "Container output tail:", tailLines(output, 80))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return lines.join("\n")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function formatDockerInnerFailure(phase: LoopPhase, summary: string): string {
|
|
154
|
+
return [
|
|
155
|
+
`OpenRalph Docker execution finished, but the ${phase} loop reported failure.`,
|
|
156
|
+
"",
|
|
157
|
+
summary,
|
|
158
|
+
"",
|
|
159
|
+
"Review the worktree before rerunning; the failed child may have left partial changes.",
|
|
160
|
+
].join("\n")
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function formatDockerFailure(phase: LoopPhase, parsed: ParsedLoopArgs, result: CommandResult): string {
|
|
164
|
+
const reason = result.exitCode === null ? `signal ${result.signal ?? "unknown"}` : `exit code ${result.exitCode}`
|
|
165
|
+
const output = commandOutput(result)
|
|
166
|
+
const lines = [
|
|
167
|
+
`OpenRalph Docker execution failed: Docker exited with ${reason}`,
|
|
168
|
+
"",
|
|
169
|
+
"OpenRalph did not fall back to host execution because Docker mode is enabled.",
|
|
170
|
+
"Fix the Docker issue and rerun the command.",
|
|
171
|
+
"",
|
|
172
|
+
"If you intentionally want to run this loop on the host, rerun with:",
|
|
173
|
+
` ${noDockerCommand(phase, parsed)}`,
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
if (output) lines.push("", "Container output tail:", tailLines(output, 80))
|
|
177
|
+
return lines.join("\n")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function preflightCommands(mode: LauncherMode, exists: typeof defaultCommandExists): Promise<void> {
|
|
181
|
+
const required = mode === "docker-host-launch" ? ["git", "docker"] : ["git", "opencode"]
|
|
182
|
+
const missing: string[] = []
|
|
183
|
+
|
|
184
|
+
for (const command of required) {
|
|
185
|
+
if (!(await exists(command))) missing.push(command)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (missing.length > 0) throw new Error(formatMissingCommands(missing))
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function formatMissingCommands(commands: string[]): string {
|
|
192
|
+
const hints: Record<string, string> = {
|
|
193
|
+
git: "Install git and ensure it is on PATH.",
|
|
194
|
+
opencode: "Install opencode (https://opencode.ai) and ensure it is on PATH.",
|
|
195
|
+
docker: "Install Docker and ensure the docker executable is on PATH.",
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
`OpenRalph required command${commands.length === 1 ? "" : "s"} not found on PATH: ${commands.join(", ")}.`,
|
|
200
|
+
...commands.map((command) => `${command}: ${hints[command] ?? "Install it and ensure it is on PATH."}`),
|
|
201
|
+
].join("\n")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function noDockerCommand(phase: LoopPhase, parsed: ParsedLoopArgs): string {
|
|
205
|
+
const replayArgs = formatLoopArgsForReplay(parsed)
|
|
206
|
+
return `/ralph-${phase}${replayArgs ? ` ${replayArgs}` : ""} --no-docker`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function commandOutput(result: CommandResult): string {
|
|
210
|
+
return `${result.stdout}\n${result.stderr}`.trim()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function translateContainerPaths(value: string | undefined, projectRoot: string | undefined): string | undefined {
|
|
214
|
+
if (!value || !projectRoot) return value
|
|
215
|
+
return value.split(`${CONTAINER_WORKSPACE}/runs/`).join(`${projectRoot}/runs/`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function extractOpenRalphSummary(phase: LoopPhase, output: string): string | undefined {
|
|
219
|
+
const lines = output.split(/\r?\n/)
|
|
220
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
221
|
+
if (lines[index].startsWith(`OpenRalph ${phase} `)) {
|
|
222
|
+
return lines.slice(index, Math.min(lines.length, index + 8)).join("\n").trim()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return undefined
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function tailLines(input: string, maxLines: number): string {
|
|
229
|
+
const lines = input.split(/\r?\n/)
|
|
230
|
+
return lines.slice(Math.max(0, lines.length - maxLines)).join("\n")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function formatError(error: unknown): string {
|
|
234
|
+
return error instanceof Error ? error.message : String(error)
|
|
235
|
+
}
|
package/src/loop.ts
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { parseLoopArgs, resolveModel, type LoopPhase, type OpenRalphOptions } from "./args.ts"
|
|
2
|
+
import { createRunArtifacts, finishRunArtifacts, startIterationArtifacts } from "./artifacts.ts"
|
|
3
|
+
import { startCommand, type CommandOutputEvent, type CommandResult } from "./exec.ts"
|
|
4
|
+
import { requireGitContext, getHead, isWorktreeClean, createLightweightTag, pushCurrentBranch } from "./git.ts"
|
|
5
|
+
import { detectBuildSentinel, isPlanComplete } from "./sentinels.ts"
|
|
6
|
+
import { createBuildTagName } from "./tags.ts"
|
|
7
|
+
import { createHostLoopToken } from "./trust.ts"
|
|
8
|
+
|
|
9
|
+
export type LoopExecutionMode = "host-explicit" | "host-config-default" | "container-attested"
|
|
10
|
+
|
|
11
|
+
export interface RunLoopInput {
|
|
12
|
+
phase: LoopPhase
|
|
13
|
+
rawArgs: string
|
|
14
|
+
cwd: string
|
|
15
|
+
options: OpenRalphOptions
|
|
16
|
+
executionMode?: LoopExecutionMode
|
|
17
|
+
streamOutput?: boolean
|
|
18
|
+
onOutput?: (event: CommandOutputEvent) => void
|
|
19
|
+
signal?: AbortSignal
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface LoopSummary {
|
|
23
|
+
phase: LoopPhase
|
|
24
|
+
status: "complete" | "max-reached" | "failed" | "stopped"
|
|
25
|
+
message: string
|
|
26
|
+
launched: number
|
|
27
|
+
tagged: number
|
|
28
|
+
blocked: number
|
|
29
|
+
warnings: string[]
|
|
30
|
+
artifacts: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runLoop(input: RunLoopInput): Promise<LoopSummary> {
|
|
34
|
+
const args = parseLoopArgs(input.phase, input.rawArgs)
|
|
35
|
+
const model = resolveModel(input.phase, args, input.options)
|
|
36
|
+
const git = await requireGitContext(input.cwd)
|
|
37
|
+
const streamOutput = input.streamOutput ?? true
|
|
38
|
+
const warnings: string[] = []
|
|
39
|
+
const artifacts = await createRunArtifacts({ projectRoot: git.root, phase: input.phase, rawArgs: input.rawArgs })
|
|
40
|
+
|
|
41
|
+
if (git.branch === "main" || git.branch === "master") {
|
|
42
|
+
const warning = `warning: running OpenRalph on ${git.branch}`
|
|
43
|
+
warnings.push(warning)
|
|
44
|
+
if (streamOutput) process.stderr.write(`OpenRalph ${warning}\n`)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const hostWarning = hostModeWarning(input.executionMode)
|
|
48
|
+
if (hostWarning) {
|
|
49
|
+
warnings.push(hostWarning)
|
|
50
|
+
if (streamOutput) process.stderr.write(`OpenRalph ${hostWarning}\n`)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const state = {
|
|
54
|
+
launched: 0,
|
|
55
|
+
tagged: 0,
|
|
56
|
+
blocked: 0,
|
|
57
|
+
consecutiveFailures: 0,
|
|
58
|
+
lastFailure: undefined as string | undefined,
|
|
59
|
+
stopRequested: false,
|
|
60
|
+
forceStopRequested: false,
|
|
61
|
+
activeChild: undefined as ReturnType<typeof startCommand>["child"] | undefined,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const requestStop = (force: boolean, announce: boolean) => {
|
|
65
|
+
if (!state.stopRequested) {
|
|
66
|
+
state.stopRequested = true
|
|
67
|
+
state.activeChild?.kill("SIGINT")
|
|
68
|
+
if (!force && announce) process.stderr.write("\nOpenRalph stop requested. Waiting for active child to exit...\n")
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (force) {
|
|
73
|
+
state.forceStopRequested = true
|
|
74
|
+
state.activeChild?.kill("SIGKILL")
|
|
75
|
+
if (announce) process.stderr.write("\nOpenRalph force stop requested. Terminating active child...\n")
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const onSigint = () => {
|
|
80
|
+
requestStop(state.stopRequested, true)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const onAbort = () => requestStop(false, false)
|
|
84
|
+
|
|
85
|
+
const loopChild = await buildLoopChildEnv(input.executionMode)
|
|
86
|
+
process.on("SIGINT", onSigint)
|
|
87
|
+
if (input.signal?.aborted) onAbort()
|
|
88
|
+
else input.signal?.addEventListener("abort", onAbort, { once: true })
|
|
89
|
+
try {
|
|
90
|
+
const runId = input.phase === "build" ? artifacts.timestampId : undefined
|
|
91
|
+
|
|
92
|
+
const finish = async (status: LoopSummary["status"], message: string): Promise<LoopSummary> => {
|
|
93
|
+
const result = summary(input.phase, status, message, state, warnings, artifacts.dir)
|
|
94
|
+
await finishRunArtifacts(artifacts, result)
|
|
95
|
+
return result
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const tagCleanBuildCommit = async (dirtyMessage: string): Promise<LoopSummary | undefined> => {
|
|
99
|
+
if (!(await isWorktreeClean(git.root))) {
|
|
100
|
+
return finish("failed", dirtyMessage)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tagName = createBuildTagName(runId ?? artifacts.timestampId, state.tagged + 1)
|
|
104
|
+
let tagged = false
|
|
105
|
+
try {
|
|
106
|
+
await createLightweightTag(git.root, tagName)
|
|
107
|
+
state.tagged += 1
|
|
108
|
+
tagged = true
|
|
109
|
+
} catch (error) {
|
|
110
|
+
const warning = `warning: failed to tag build commit (${tagName}): ${formatError(error)}`
|
|
111
|
+
warnings.push(warning)
|
|
112
|
+
if (streamOutput) process.stderr.write(`OpenRalph ${warning}\n`)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (args.push) {
|
|
116
|
+
try {
|
|
117
|
+
await pushCurrentBranch(git.root, git.branch)
|
|
118
|
+
} catch (error) {
|
|
119
|
+
const tagStatus = tagged ? " and is tagged locally" : ""
|
|
120
|
+
return finish(
|
|
121
|
+
"failed",
|
|
122
|
+
`build commit succeeded${tagStatus}, but pushing ${git.branch} failed: ${formatError(error)}. Push manually from the host.`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return undefined
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
while (!state.stopRequested) {
|
|
131
|
+
if (args.maxIterations !== undefined && state.launched >= args.maxIterations) {
|
|
132
|
+
if (state.consecutiveFailures > 0) {
|
|
133
|
+
return finish("failed", `reached max iterations (${args.maxIterations}) after a failed child; last failure: ${state.lastFailure}`)
|
|
134
|
+
}
|
|
135
|
+
return finish("max-reached", `reached max iterations (${args.maxIterations})`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const beforeHead = input.phase === "build" ? await getHead(git.root) : undefined
|
|
139
|
+
const childArgs = buildChildArgs(input.phase, git.root, model)
|
|
140
|
+
state.launched += 1
|
|
141
|
+
const iterationArtifacts = await startIterationArtifacts(artifacts, state.launched, childArgs)
|
|
142
|
+
|
|
143
|
+
let result: CommandResult
|
|
144
|
+
try {
|
|
145
|
+
const child = startCommand("opencode", childArgs, {
|
|
146
|
+
cwd: git.root,
|
|
147
|
+
env: loopChild.env,
|
|
148
|
+
streamOutput,
|
|
149
|
+
onOutput: (event) => {
|
|
150
|
+
iterationArtifacts.recordOutput(event)
|
|
151
|
+
input.onOutput?.(event)
|
|
152
|
+
},
|
|
153
|
+
signal: input.signal,
|
|
154
|
+
})
|
|
155
|
+
state.activeChild = child.child
|
|
156
|
+
result = await child.result
|
|
157
|
+
} catch (error) {
|
|
158
|
+
state.activeChild = undefined
|
|
159
|
+
await iterationArtifacts.finish({ error })
|
|
160
|
+
if (state.stopRequested || state.forceStopRequested || input.signal?.aborted) {
|
|
161
|
+
return finish("stopped", `stopped by user after ${state.launched} launched iteration(s)`)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const failed = recordFailure(input.phase, state, `child process failed to start: ${formatError(error)}`, streamOutput)
|
|
165
|
+
if (failed) return finish("failed", failed)
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
state.activeChild = undefined
|
|
169
|
+
|
|
170
|
+
const output = `${result.stdout}\n${result.stderr}`
|
|
171
|
+
|
|
172
|
+
if (state.stopRequested || state.forceStopRequested || input.signal?.aborted) {
|
|
173
|
+
await iterationArtifacts.finish({ result, status: "stopped by user" })
|
|
174
|
+
return finish("stopped", `stopped by user after ${state.launched} launched iteration(s)`)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (result.exitCode !== 0) {
|
|
178
|
+
await iterationArtifacts.finish({ result, status: "child process failed" })
|
|
179
|
+
const failed = recordFailure(input.phase, state, `child process failed with exit code ${result.exitCode ?? `signal ${result.signal}`}`, streamOutput)
|
|
180
|
+
if (failed) return finish("failed", failed)
|
|
181
|
+
continue
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (input.phase === "plan") {
|
|
185
|
+
const planComplete = isPlanComplete(output)
|
|
186
|
+
await iterationArtifacts.finish({ result, status: planComplete ? "planning complete" : "planning continues", sentinel: planComplete ? "RALPH_PLAN_COMPLETE" : undefined })
|
|
187
|
+
state.consecutiveFailures = 0
|
|
188
|
+
state.lastFailure = undefined
|
|
189
|
+
if (planComplete) {
|
|
190
|
+
return finish("complete", "planning complete")
|
|
191
|
+
}
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const sentinel = detectBuildSentinel(output)
|
|
196
|
+
await iterationArtifacts.finish({ result, status: `build sentinel: ${sentinel}`, sentinel: sentinel === "none" ? undefined : sentinel })
|
|
197
|
+
if (sentinel === "complete") {
|
|
198
|
+
const afterHead = await getHead(git.root)
|
|
199
|
+
if (afterHead && afterHead !== beforeHead) {
|
|
200
|
+
const failed = await tagCleanBuildCommit("build completion reported with a dirty worktree; not tagging")
|
|
201
|
+
if (failed) return failed
|
|
202
|
+
} else if (!(await isWorktreeClean(git.root))) {
|
|
203
|
+
return finish("failed", "build completion reported with a dirty worktree; completed work must be committed before finishing")
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
state.consecutiveFailures = 0
|
|
207
|
+
state.lastFailure = undefined
|
|
208
|
+
return finish("complete", "build complete")
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (sentinel === "blocked") {
|
|
212
|
+
state.consecutiveFailures = 0
|
|
213
|
+
state.lastFailure = undefined
|
|
214
|
+
state.blocked += 1
|
|
215
|
+
continue
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (sentinel === "none") {
|
|
219
|
+
const failed = recordFailure(input.phase, state, "build child exited successfully without a Ralph sentinel", streamOutput)
|
|
220
|
+
if (failed) return finish("failed", failed)
|
|
221
|
+
continue
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const afterHead = await getHead(git.root)
|
|
225
|
+
if (!afterHead || afterHead === beforeHead) {
|
|
226
|
+
const failed = recordFailure(input.phase, state, "build iteration reported completion but did not create a new commit", streamOutput)
|
|
227
|
+
if (failed) return finish("failed", failed)
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const failed = await tagCleanBuildCommit("build iteration completed with a dirty worktree; not tagging")
|
|
232
|
+
if (failed) return failed
|
|
233
|
+
|
|
234
|
+
state.consecutiveFailures = 0
|
|
235
|
+
state.lastFailure = undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return finish("stopped", `stopped by user after ${state.launched} launched iteration(s)`)
|
|
239
|
+
} finally {
|
|
240
|
+
process.off("SIGINT", onSigint)
|
|
241
|
+
input.signal?.removeEventListener("abort", onAbort)
|
|
242
|
+
await loopChild.cleanup()
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function buildLoopChildEnv(executionMode: LoopExecutionMode | undefined): Promise<{
|
|
247
|
+
env: Record<string, string>
|
|
248
|
+
cleanup: () => Promise<void>
|
|
249
|
+
}> {
|
|
250
|
+
if (executionMode === "container-attested") {
|
|
251
|
+
return { env: { OPENRALPH_LOOP_CHILD: "1" }, cleanup: async () => {} }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const hostToken = await createHostLoopToken()
|
|
255
|
+
return {
|
|
256
|
+
env: {
|
|
257
|
+
OPENRALPH_LOOP_CHILD: "1",
|
|
258
|
+
...hostToken.env,
|
|
259
|
+
},
|
|
260
|
+
cleanup: hostToken.cleanup,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function buildChildArgs(phase: LoopPhase, projectRoot: string, model: string | undefined): string[] {
|
|
265
|
+
const args = [
|
|
266
|
+
"run",
|
|
267
|
+
"--dir",
|
|
268
|
+
projectRoot,
|
|
269
|
+
"--command",
|
|
270
|
+
phase === "plan" ? "ralph-plan-iteration" : "ralph-build-iteration",
|
|
271
|
+
"--dangerously-skip-permissions",
|
|
272
|
+
]
|
|
273
|
+
|
|
274
|
+
if (model) args.push("--model", model)
|
|
275
|
+
return args
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function hostModeWarning(mode: LoopExecutionMode | undefined): string | undefined {
|
|
279
|
+
if (mode === "container-attested") return undefined
|
|
280
|
+
if (mode === "host-explicit") {
|
|
281
|
+
return "warning: --no-docker selected; child iterations run on the host with --dangerously-skip-permissions"
|
|
282
|
+
}
|
|
283
|
+
return "warning: host mode runs child iterations on this machine with --dangerously-skip-permissions"
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function recordFailure(
|
|
287
|
+
phase: LoopPhase,
|
|
288
|
+
state: { consecutiveFailures: number; lastFailure?: string },
|
|
289
|
+
reason: string,
|
|
290
|
+
streamOutput: boolean,
|
|
291
|
+
): string | undefined {
|
|
292
|
+
state.consecutiveFailures += 1
|
|
293
|
+
state.lastFailure = reason
|
|
294
|
+
if (streamOutput) process.stderr.write(`\nOpenRalph ${phase} iteration failed: ${reason}\n`)
|
|
295
|
+
if (state.consecutiveFailures >= 3) {
|
|
296
|
+
return `stopped after 3 consecutive child failures; last failure: ${reason}`
|
|
297
|
+
}
|
|
298
|
+
return undefined
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function summary(
|
|
302
|
+
phase: LoopPhase,
|
|
303
|
+
status: LoopSummary["status"],
|
|
304
|
+
message: string,
|
|
305
|
+
state: { launched: number; tagged: number; blocked: number },
|
|
306
|
+
warnings: string[],
|
|
307
|
+
artifacts: string,
|
|
308
|
+
): LoopSummary {
|
|
309
|
+
return {
|
|
310
|
+
phase,
|
|
311
|
+
status,
|
|
312
|
+
message,
|
|
313
|
+
launched: state.launched,
|
|
314
|
+
tagged: state.tagged,
|
|
315
|
+
blocked: state.blocked,
|
|
316
|
+
warnings,
|
|
317
|
+
artifacts,
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function formatSummary(summary: LoopSummary): string {
|
|
322
|
+
const lines = [
|
|
323
|
+
`OpenRalph ${summary.phase} ${summary.status}: ${summary.message}`,
|
|
324
|
+
`launched iterations: ${summary.launched}`,
|
|
325
|
+
`artifacts: ${summary.artifacts}`,
|
|
326
|
+
]
|
|
327
|
+
|
|
328
|
+
if (summary.phase === "build") {
|
|
329
|
+
lines.push(`tagged iterations: ${summary.tagged}`)
|
|
330
|
+
lines.push(`blocked iterations: ${summary.blocked}`)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const warning of summary.warnings) lines.push(warning)
|
|
334
|
+
return lines.join("\n")
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function formatError(error: unknown): string {
|
|
338
|
+
return error instanceof Error ? error.message : String(error)
|
|
339
|
+
}
|
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { fileURLToPath } from "node:url"
|
|
4
|
+
import type { Plugin } from "@opencode-ai/plugin"
|
|
5
|
+
import { validateOptions, type OpenRalphOptions } from "./args.ts"
|
|
6
|
+
import { authorizeLoopChild } from "./trust.ts"
|
|
7
|
+
|
|
8
|
+
const pluginDir = dirname(fileURLToPath(import.meta.url))
|
|
9
|
+
const packageRoot = resolve(pluginDir, "..")
|
|
10
|
+
const ORCHESTRATOR_AGENT = "openralph-orchestrator"
|
|
11
|
+
|
|
12
|
+
export default (async ({ directory }, rawOptions) => {
|
|
13
|
+
validateOptions(rawOptions)
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
config: async (cfg) => {
|
|
17
|
+
cfg.command ??= {}
|
|
18
|
+
delete cfg.command["ralph-define"]
|
|
19
|
+
delete cfg.command["ralph-plan"]
|
|
20
|
+
delete cfg.command["ralph-build"]
|
|
21
|
+
if (cfg.agent) delete cfg.agent[ORCHESTRATOR_AGENT]
|
|
22
|
+
|
|
23
|
+
if (await authorizeLoopChild(process.env, directory)) {
|
|
24
|
+
cfg.command["ralph-plan-iteration"] = {
|
|
25
|
+
description: "Run one Ralph planning iteration",
|
|
26
|
+
template: readPrompt("PROMPT_plan.md"),
|
|
27
|
+
}
|
|
28
|
+
cfg.command["ralph-build-iteration"] = {
|
|
29
|
+
description: "Run one Ralph build iteration",
|
|
30
|
+
template: readPrompt("PROMPT_build.md"),
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"command.execute.before": async (input, output) => {
|
|
35
|
+
if (input.command !== "ralph-define" && input.command !== "ralph-plan" && input.command !== "ralph-build") return
|
|
36
|
+
output.parts = []
|
|
37
|
+
throw new Error(
|
|
38
|
+
`/${input.command} has been replaced by the OpenRalph TUI menu. Remove any stale prompt-backed ${input.command} command file/config, load the OpenRalph TUI plugin, and run /ralph instead.`,
|
|
39
|
+
)
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}) satisfies Plugin
|
|
43
|
+
|
|
44
|
+
function readPrompt(fileName: string): string {
|
|
45
|
+
try {
|
|
46
|
+
return readFileSync(resolve(packageRoot, fileName), "utf8")
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`OpenRalph could not read bundled prompt ${fileName}; the package may be installed incompletely: ${formatError(error)}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function formatError(error: unknown): string {
|
|
53
|
+
return error instanceof Error ? error.message : String(error)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type { OpenRalphOptions }
|