@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/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
+ }
@@ -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 }