@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.
@@ -0,0 +1,58 @@
1
+ FROM oven/bun:1.2.22-debian
2
+
3
+ ARG OPENCODE_VERSION=1.15.13
4
+ ARG CHROME_DEVTOOLS_MCP_VERSION=1.1.1
5
+ ARG NODE_MAJOR=22
6
+
7
+ ENV BUN_INSTALL=/usr/local
8
+ ENV PATH=/opt/openralph/bin:/usr/local/bin:${PATH}
9
+
10
+ RUN apt-get update \
11
+ && apt-get install -y --no-install-recommends bash ca-certificates curl git gnupg \
12
+ && mkdir -p /etc/apt/keyrings \
13
+ && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
14
+ && printf 'deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_%s.x nodistro main\n' "${NODE_MAJOR}" > /etc/apt/sources.list.d/nodesource.list \
15
+ && curl -fsSL https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /etc/apt/keyrings/google-linux.gpg \
16
+ && printf 'deb [arch=amd64 signed-by=/etc/apt/keyrings/google-linux.gpg] https://dl.google.com/linux/chrome/deb/ stable main\n' > /etc/apt/sources.list.d/google-chrome.list \
17
+ && apt-get update \
18
+ && apt-get install -y --no-install-recommends \
19
+ build-essential \
20
+ fonts-dejavu \
21
+ fonts-liberation \
22
+ fonts-noto-color-emoji \
23
+ fonts-noto-core \
24
+ google-chrome-stable \
25
+ nodejs \
26
+ pkg-config \
27
+ python3 \
28
+ python3-pip \
29
+ python3-venv \
30
+ ripgrep \
31
+ && corepack enable \
32
+ && npm install --global chrome-devtools-mcp@${CHROME_DEVTOOLS_MCP_VERSION} \
33
+ && npm cache clean --force \
34
+ && rm -rf /var/lib/apt/lists/*
35
+
36
+ RUN bun install --global opencode-ai@${OPENCODE_VERSION}
37
+
38
+ WORKDIR /opt/openralph
39
+
40
+ COPY package.json bun.lock tsconfig.json ./
41
+ COPY src ./src
42
+ COPY container/bin ./bin
43
+ COPY bin/openralph ./bin/openralph
44
+ COPY AGENTS.md README.md PROMPT_build.md PROMPT_plan.md ./
45
+
46
+ RUN bun install --production --frozen-lockfile
47
+
48
+ RUN chmod +x /opt/openralph/bin/chrome-devtools-mcp-wrapper /opt/openralph/bin/openralph
49
+
50
+ RUN useradd --create-home --shell /bin/bash opencode \
51
+ && mkdir -p /home/opencode/.local/share/opencode /workspace \
52
+ && chmod -R 0777 /home/opencode /workspace
53
+
54
+ ENV HOME=/home/opencode
55
+
56
+ USER opencode
57
+
58
+ WORKDIR /workspace
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ profile="$(mktemp -d "${TMPDIR:-/tmp}/openralph-chrome-profile.XXXXXX")"
5
+ log_file="${TMPDIR:-/tmp}/openralph-chrome-$$.log"
6
+ chrome_pid=""
7
+ port=""
8
+ ready="false"
9
+
10
+ cleanup() {
11
+ if [ -n "${chrome_pid}" ] && kill -0 "${chrome_pid}" 2>/dev/null; then
12
+ kill "${chrome_pid}" 2>/dev/null || true
13
+ wait "${chrome_pid}" 2>/dev/null || true
14
+ fi
15
+ rm -rf "${profile}" 2>/dev/null || true
16
+ }
17
+
18
+ trap cleanup EXIT INT TERM
19
+
20
+ google-chrome \
21
+ --headless \
22
+ --disable-gpu \
23
+ --no-sandbox \
24
+ --remote-debugging-address=127.0.0.1 \
25
+ --remote-debugging-port=0 \
26
+ --user-data-dir="${profile}" \
27
+ --window-size=1440,1000 \
28
+ about:blank >"${log_file}" 2>&1 &
29
+ chrome_pid=$!
30
+
31
+ port_file="${profile}/DevToolsActivePort"
32
+ attempts=0
33
+ while [ "${attempts}" -lt 100 ]; do
34
+ attempts=$((attempts + 1))
35
+ if [ -s "${port_file}" ]; then
36
+ IFS= read -r port <"${port_file}"
37
+ if [ -n "${port}" ] && curl -fsS "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1; then
38
+ ready="true"
39
+ break
40
+ fi
41
+ fi
42
+
43
+ if ! kill -0 "${chrome_pid}" 2>/dev/null; then
44
+ sed -n '1,120p' "${log_file}" >&2 || true
45
+ exit 1
46
+ fi
47
+
48
+ sleep 0.1
49
+ done
50
+
51
+ if [ "${ready}" != "true" ]; then
52
+ sed -n '1,120p' "${log_file}" >&2 || true
53
+ printf 'Chrome did not start remote debugging for Chrome DevTools MCP.\n' >&2
54
+ exit 1
55
+ fi
56
+
57
+ chrome-devtools-mcp --browser-url="http://127.0.0.1:${port}" "$@"
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@john-ezra/openralph",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Light opencode plugin for the Ralph workflow.",
6
+ "license": "MIT",
7
+ "main": "./src/plugin.ts",
8
+ "exports": {
9
+ ".": "./src/plugin.ts",
10
+ "./server": "./src/plugin.ts",
11
+ "./tui": "./src/tui.ts",
12
+ "./cli": "./src/cli.ts",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "bin": {
16
+ "openralph": "./bin/openralph"
17
+ },
18
+ "engines": {
19
+ "bun": ">=1.2.0"
20
+ },
21
+ "files": [
22
+ ".dockerignore",
23
+ "AGENTS.md",
24
+ "CHANGELOG.md",
25
+ "LICENSE",
26
+ "README.md",
27
+ "PROMPT_build.md",
28
+ "PROMPT_plan.md",
29
+ "bin/openralph",
30
+ "bun.lock",
31
+ "container/",
32
+ "src/",
33
+ "tsconfig.json"
34
+ ],
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "typecheck": "tsc --noEmit",
40
+ "test": "bun test",
41
+ "release:check": "bun src/release-check.ts",
42
+ "validate": "bun run typecheck && bun test && bun run release:check",
43
+ "prepublishOnly": "bun run validate"
44
+ },
45
+ "devDependencies": {
46
+ "@opencode-ai/plugin": "1.15.13",
47
+ "@types/bun": "latest",
48
+ "typescript": "latest"
49
+ }
50
+ }
package/src/args.ts ADDED
@@ -0,0 +1,236 @@
1
+ export type LoopPhase = "plan" | "build"
2
+
3
+ export const DEFAULT_DOCKER_IMAGE = "openralph:local"
4
+
5
+ const DOCKER_DOMAIN_COMPONENT = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?"
6
+ const DOCKER_NAME_COMPONENT = "[a-z0-9]+(?:(?:[._-]+|__)[a-z0-9]+)*"
7
+ const DOCKER_IMAGE_PATTERN = new RegExp(
8
+ `^(?=.{1,255}$)(?:${DOCKER_DOMAIN_COMPONENT}(?:\\.${DOCKER_DOMAIN_COMPONENT})*(?::[0-9]+)?/)?${DOCKER_NAME_COMPONENT}(?:/${DOCKER_NAME_COMPONENT})*(?::[A-Za-z0-9_][A-Za-z0-9_.-]{0,127})?(?:@sha256:[A-Fa-f0-9]{64})?$`,
9
+ )
10
+
11
+ export interface OpenRalphOptions {
12
+ defineModel?: string
13
+ planModel?: string
14
+ buildModel?: string
15
+ docker?: DockerOptions
16
+ }
17
+
18
+ export interface DockerOptions {
19
+ enabled?: boolean
20
+ image?: string
21
+ maskEnv?: boolean
22
+ }
23
+
24
+ export interface ResolvedDockerOptions {
25
+ enabled: boolean
26
+ image: string
27
+ maskEnv: boolean
28
+ }
29
+
30
+ export interface ParsedLoopArgs {
31
+ maxIterations?: number
32
+ model?: string
33
+ push: boolean
34
+ noDocker: boolean
35
+ }
36
+
37
+ export function validateOptions(input: unknown): OpenRalphOptions {
38
+ if (input == null) return {}
39
+ if (typeof input !== "object" || Array.isArray(input)) {
40
+ throw new Error("OpenRalph plugin options must be an object")
41
+ }
42
+
43
+ const options = input as Record<string, unknown>
44
+ return {
45
+ defineModel: readOptionalModel(options, "defineModel"),
46
+ planModel: readOptionalModel(options, "planModel"),
47
+ buildModel: readOptionalModel(options, "buildModel"),
48
+ docker: readDockerOptions(options),
49
+ }
50
+ }
51
+
52
+ export function parseLoopArgs(phase: LoopPhase, raw: string): ParsedLoopArgs {
53
+ const tokens = splitArgs(raw)
54
+ const parsed: ParsedLoopArgs = { push: false, noDocker: false }
55
+ let hasMax = false
56
+
57
+ for (let index = 0; index < tokens.length; index += 1) {
58
+ const token = tokens[index]
59
+
60
+ if (token === "--model") {
61
+ if (parsed.model) throw new Error("--model can only be provided once")
62
+ const value = tokens[index + 1]
63
+ if (!value || !isValidModelValue(value)) {
64
+ throw new Error("--model requires a provider/model value")
65
+ }
66
+ parsed.model = value
67
+ index += 1
68
+ continue
69
+ }
70
+
71
+ if (token === "--push") {
72
+ if (phase !== "build") throw new Error("--push is only supported for OpenRalph Build")
73
+ if (parsed.push) throw new Error("--push can only be provided once")
74
+ parsed.push = true
75
+ continue
76
+ }
77
+
78
+ if (token === "--no-docker") {
79
+ if (parsed.noDocker) throw new Error("--no-docker can only be provided once")
80
+ parsed.noDocker = true
81
+ continue
82
+ }
83
+
84
+ if (token.startsWith("-")) {
85
+ throw new Error(`Unknown flag: ${token}`)
86
+ }
87
+
88
+ if (!isPositiveIntegerToken(token)) {
89
+ throw new Error(`Unexpected argument: ${token}`)
90
+ }
91
+ if (hasMax) throw new Error("max iterations can only be provided once")
92
+
93
+ const maxIterations = Number(token)
94
+ if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) {
95
+ throw new Error("max iterations must be a positive integer")
96
+ }
97
+
98
+ parsed.maxIterations = maxIterations
99
+ hasMax = true
100
+ }
101
+
102
+ return parsed
103
+ }
104
+
105
+ export function formatLoopArgsForReplay(args: ParsedLoopArgs): string {
106
+ const tokens: string[] = []
107
+ if (args.maxIterations !== undefined) tokens.push(String(args.maxIterations))
108
+ if (args.model) tokens.push("--model", args.model)
109
+ if (args.push) tokens.push("--push")
110
+ return tokens.map(quoteArg).join(" ")
111
+ }
112
+
113
+ export function resolveDockerOptions(options: OpenRalphOptions): ResolvedDockerOptions {
114
+ return {
115
+ enabled: options.docker?.enabled ?? false,
116
+ image: options.docker?.image ?? DEFAULT_DOCKER_IMAGE,
117
+ maskEnv: options.docker?.maskEnv ?? true,
118
+ }
119
+ }
120
+
121
+ export function validateDockerImageReference(value: string, label = "docker.image"): string {
122
+ if (!DOCKER_IMAGE_PATTERN.test(value)) {
123
+ throw new Error(`${label} must be a valid Docker image reference`)
124
+ }
125
+ return value
126
+ }
127
+
128
+ export function resolveModel(
129
+ phase: LoopPhase,
130
+ args: ParsedLoopArgs,
131
+ options: OpenRalphOptions,
132
+ ): string | undefined {
133
+ if (args.model) return args.model
134
+ return phase === "plan" ? options.planModel : options.buildModel
135
+ }
136
+
137
+ function readOptionalString(options: Record<string, unknown>, key: "defineModel" | "planModel" | "buildModel" | "image"): string | undefined {
138
+ const value = options[key]
139
+ if (value == null) return undefined
140
+ if (typeof value !== "string") throw new Error(`${key} must be a string when provided`)
141
+ if (value.trim() === "") throw new Error(`${key} must not be empty when provided`)
142
+ return value
143
+ }
144
+
145
+ function readOptionalModel(options: Record<string, unknown>, key: "defineModel" | "planModel" | "buildModel"): string | undefined {
146
+ const value = readOptionalString(options, key)
147
+ if (value === undefined) return undefined
148
+ if (!isValidModelValue(value)) throw new Error(`${key} must be a provider/model value`)
149
+ return value
150
+ }
151
+
152
+ function readDockerOptions(options: Record<string, unknown>): DockerOptions | undefined {
153
+ const value = options.docker
154
+ if (value == null) return undefined
155
+ if (typeof value !== "object" || Array.isArray(value)) {
156
+ throw new Error("docker must be an object when provided")
157
+ }
158
+
159
+ const docker = value as Record<string, unknown>
160
+ const image = readOptionalString(docker, "image")
161
+ return {
162
+ enabled: readOptionalBoolean(docker, "enabled"),
163
+ image: image === undefined ? undefined : validateDockerImageReference(image),
164
+ maskEnv: readOptionalBoolean(docker, "maskEnv"),
165
+ }
166
+ }
167
+
168
+ function readOptionalBoolean(options: Record<string, unknown>, key: "enabled" | "maskEnv"): boolean | undefined {
169
+ const value = options[key]
170
+ if (value == null) return undefined
171
+ if (typeof value !== "boolean") throw new Error(`docker.${key} must be a boolean when provided`)
172
+ return value
173
+ }
174
+
175
+ function isPositiveIntegerToken(token: string): boolean {
176
+ return /^[0-9]+$/.test(token) && Number(token) > 0
177
+ }
178
+
179
+ function isValidModelValue(value: string): boolean {
180
+ const slash = value.indexOf("/")
181
+ if (slash <= 0 || slash === value.length - 1) return false
182
+ const provider = value.slice(0, slash)
183
+ const model = value.slice(slash + 1)
184
+ return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(provider) && !model.startsWith("-") && !/[,\s]/.test(model)
185
+ }
186
+
187
+ function splitArgs(input: string): string[] {
188
+ const tokens: string[] = []
189
+ let current = ""
190
+ let quote: '"' | "'" | undefined
191
+ let escaping = false
192
+
193
+ for (const char of input.trim()) {
194
+ if (escaping) {
195
+ current += char
196
+ escaping = false
197
+ continue
198
+ }
199
+
200
+ if (char === "\\" && quote !== "'") {
201
+ escaping = true
202
+ continue
203
+ }
204
+
205
+ if (quote) {
206
+ if (char === quote) quote = undefined
207
+ else current += char
208
+ continue
209
+ }
210
+
211
+ if (char === '"' || char === "'") {
212
+ quote = char
213
+ continue
214
+ }
215
+
216
+ if (/\s/.test(char)) {
217
+ if (current) {
218
+ tokens.push(current)
219
+ current = ""
220
+ }
221
+ continue
222
+ }
223
+
224
+ current += char
225
+ }
226
+
227
+ if (escaping) current += "\\"
228
+ if (quote) throw new Error("Unterminated quoted argument")
229
+ if (current) tokens.push(current)
230
+ return tokens
231
+ }
232
+
233
+ function quoteArg(token: string): string {
234
+ if (/^[^\s"'\\]+$/.test(token)) return token
235
+ return `"${token.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
236
+ }
@@ -0,0 +1,183 @@
1
+ import { appendFile, mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+ import type { LoopPhase } from "./args.ts"
4
+ import type { CommandOutputEvent, CommandResult } from "./exec.ts"
5
+ import { createTimestampId } from "./tags.ts"
6
+
7
+ export interface CreateRunArtifactsInput {
8
+ projectRoot: string
9
+ phase: LoopPhase
10
+ rawArgs: string
11
+ date?: Date
12
+ }
13
+
14
+ export interface RunArtifacts {
15
+ phase: LoopPhase
16
+ timestampId: string
17
+ runName: string
18
+ dir: string
19
+ logPath: string
20
+ }
21
+
22
+ export interface IterationArtifacts {
23
+ index: number
24
+ jsonlPath: string
25
+ textPath: string
26
+ recordOutput: (event: CommandOutputEvent) => void
27
+ finish: (outcome: IterationOutcome) => Promise<void>
28
+ }
29
+
30
+ export interface IterationOutcome {
31
+ result?: Pick<CommandResult, "exitCode" | "signal">
32
+ status?: string
33
+ error?: unknown
34
+ sentinel?: string
35
+ }
36
+
37
+ export interface RunArtifactSummary {
38
+ phase: LoopPhase
39
+ status: string
40
+ message: string
41
+ launched: number
42
+ tagged: number
43
+ blocked: number
44
+ warnings: string[]
45
+ }
46
+
47
+ export async function createRunArtifacts(input: CreateRunArtifactsInput): Promise<RunArtifacts> {
48
+ const timestampId = createTimestampId(input.date)
49
+ const runsDir = join(input.projectRoot, "runs")
50
+ await mkdir(runsDir, { recursive: true })
51
+ await ensureRunsIgnored(runsDir)
52
+
53
+ const runName = createRunArtifactName(input.phase, timestampId)
54
+ const dir = join(runsDir, runName)
55
+ const logPath = join(dir, "ralph.log")
56
+ const run: RunArtifacts = { phase: input.phase, timestampId, runName, dir, logPath }
57
+
58
+ await mkdir(dir, { recursive: true })
59
+ await appendRunLog(run, [
60
+ `OpenRalph ${input.phase} run started`,
61
+ `time: ${(input.date ?? new Date()).toISOString()}`,
62
+ `project: ${input.projectRoot}`,
63
+ `args: ${input.rawArgs.trim() || "(default)"}`,
64
+ "",
65
+ ])
66
+
67
+ return run
68
+ }
69
+
70
+ export function createRunArtifactName(phase: LoopPhase, timestampId: string): string {
71
+ return `openralph-${phase}-${timestampId}`
72
+ }
73
+
74
+ export async function startIterationArtifacts(run: RunArtifacts, index: number, childArgs: string[]): Promise<IterationArtifacts> {
75
+ const iteration = formatIteration(index)
76
+ const jsonlPath = join(run.dir, `${iteration}.jsonl`)
77
+ const textPath = join(run.dir, `${iteration}.txt`)
78
+ let queue = Promise.resolve()
79
+ let writeError: unknown
80
+
81
+ await writeFile(jsonlPath, "")
82
+ await writeFile(textPath, "")
83
+ await appendRunLog(run, [
84
+ `${iteration} started`,
85
+ `time: ${new Date().toISOString()}`,
86
+ `command: opencode ${childArgs.map(quoteArg).join(" ")}`,
87
+ "",
88
+ ])
89
+
90
+ const enqueue = (write: () => Promise<void>) => {
91
+ queue = queue.then(write, write).catch((error) => {
92
+ writeError ??= error
93
+ })
94
+ }
95
+
96
+ return {
97
+ index,
98
+ jsonlPath,
99
+ textPath,
100
+ recordOutput: (event) => {
101
+ const entry = JSON.stringify({
102
+ time: new Date().toISOString(),
103
+ iteration: index,
104
+ type: "output",
105
+ stream: event.stream,
106
+ chunk: event.chunk,
107
+ })
108
+
109
+ enqueue(async () => {
110
+ await appendFile(jsonlPath, `${entry}\n`)
111
+ await appendFile(textPath, event.chunk)
112
+ })
113
+ },
114
+ finish: async (outcome) => {
115
+ await queue
116
+ if (writeError) throw writeError
117
+
118
+ await appendRunLog(run, [
119
+ `${iteration} finished`,
120
+ `time: ${new Date().toISOString()}`,
121
+ `status: ${formatOutcomeStatus(outcome)}`,
122
+ ...(outcome.sentinel ? [`sentinel: ${outcome.sentinel}`] : []),
123
+ "",
124
+ ])
125
+ },
126
+ }
127
+ }
128
+
129
+ export async function finishRunArtifacts(run: RunArtifacts, summary: RunArtifactSummary): Promise<void> {
130
+ await appendRunLog(run, [
131
+ `OpenRalph ${summary.phase} run finished`,
132
+ `time: ${new Date().toISOString()}`,
133
+ `status: ${summary.status}`,
134
+ `message: ${summary.message}`,
135
+ `launched iterations: ${summary.launched}`,
136
+ ...(summary.phase === "build" ? [`tagged iterations: ${summary.tagged}`, `blocked iterations: ${summary.blocked}`] : []),
137
+ ...summary.warnings,
138
+ "",
139
+ ])
140
+ }
141
+
142
+ async function ensureRunsIgnored(runsDir: string): Promise<void> {
143
+ const ignorePath = join(runsDir, ".gitignore")
144
+ const ignoreAll = "*"
145
+
146
+ try {
147
+ const current = await readFile(ignorePath, "utf8")
148
+ if (current.split(/\r?\n/).some((line) => line.trim() === ignoreAll)) return
149
+ await appendFile(ignorePath, `${current.endsWith("\n") || current.length === 0 ? "" : "\n"}${ignoreAll}\n`)
150
+ } catch (error) {
151
+ if (!isNotFound(error)) throw error
152
+ await writeFile(ignorePath, `${ignoreAll}\n`)
153
+ }
154
+ }
155
+
156
+ async function appendRunLog(run: RunArtifacts, lines: string[]): Promise<void> {
157
+ await appendFile(run.logPath, `${lines.join("\n")}\n`)
158
+ }
159
+
160
+ function formatIteration(index: number): string {
161
+ return `iter-${String(index).padStart(3, "0")}`
162
+ }
163
+
164
+ function formatOutcomeStatus(outcome: IterationOutcome): string {
165
+ if (outcome.error) return `error: ${formatError(outcome.error)}`
166
+ if (outcome.status) return outcome.status
167
+ if (!outcome.result) return "unknown"
168
+ if (outcome.result.exitCode === 0) return "exit 0"
169
+ return outcome.result.exitCode === null ? `signal ${outcome.result.signal ?? "unknown"}` : `exit ${outcome.result.exitCode}`
170
+ }
171
+
172
+ function quoteArg(arg: string): string {
173
+ if (/^[^\s"'\\]+$/.test(arg)) return arg
174
+ return `"${arg.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`
175
+ }
176
+
177
+ function isNotFound(error: unknown): boolean {
178
+ return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "ENOENT"
179
+ }
180
+
181
+ function formatError(error: unknown): string {
182
+ return error instanceof Error ? error.message : String(error)
183
+ }