ralph-codex 0.1.0

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,1129 @@
1
+ import { spawn, spawnSync } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import enquirer from "enquirer";
6
+ import yaml from "js-yaml";
7
+ import { colors, createLogStyler, createSpinner } from "../ui/terminal.js";
8
+
9
+ const { AutoComplete, Confirm, Editor, Input, MultiSelect } = enquirer;
10
+
11
+ const root = process.cwd();
12
+ const agentDir = path.join(root, ".ralph");
13
+
14
+ const argv = process.argv.slice(2);
15
+ let maxIterations = "1";
16
+ let tasksPath = "tasks.md";
17
+ let noSandbox = false;
18
+ let sandbox = null;
19
+ let fullAuto = false;
20
+ let askForApproval = null;
21
+ let model = null;
22
+ let profile = null;
23
+ let configPath = null;
24
+ let modelReasoningEffort = null;
25
+ let activeDockerConfig = null;
26
+ let autoDetectSuccessCriteria = null;
27
+ let reasoningChoice;
28
+ let showHelp = false;
29
+ const ideaParts = [];
30
+
31
+ for (let i = 0; i < argv.length; i += 1) {
32
+ const arg = argv[i];
33
+ if (arg === "--help" || arg === "-h" || arg === "help") {
34
+ showHelp = true;
35
+ continue;
36
+ }
37
+ if (arg === "--max-iterations") {
38
+ maxIterations = argv[i + 1];
39
+ i += 1;
40
+ continue;
41
+ }
42
+ if (arg === "--tasks") {
43
+ tasksPath = argv[i + 1];
44
+ i += 1;
45
+ continue;
46
+ }
47
+ if (arg === "--output") {
48
+ tasksPath = argv[i + 1];
49
+ i += 1;
50
+ continue;
51
+ }
52
+ if (arg === "--no-sandbox") {
53
+ noSandbox = true;
54
+ continue;
55
+ }
56
+ if (arg === "--sandbox") {
57
+ sandbox = argv[i + 1];
58
+ i += 1;
59
+ continue;
60
+ }
61
+ if (arg === "--full-auto") {
62
+ fullAuto = true;
63
+ continue;
64
+ }
65
+ if (arg === "--ask-for-approval") {
66
+ askForApproval = argv[i + 1];
67
+ i += 1;
68
+ continue;
69
+ }
70
+ if (arg === "--model" || arg === "-m") {
71
+ model = argv[i + 1];
72
+ i += 1;
73
+ continue;
74
+ }
75
+ if (arg === "--profile" || arg === "-p") {
76
+ profile = argv[i + 1];
77
+ i += 1;
78
+ continue;
79
+ }
80
+ if (arg === "--config") {
81
+ configPath = argv[i + 1];
82
+ i += 1;
83
+ continue;
84
+ }
85
+ if (arg === "--reasoning") {
86
+ const next = argv[i + 1];
87
+ if (next && !next.startsWith("-")) {
88
+ reasoningChoice = next;
89
+ i += 1;
90
+ } else {
91
+ reasoningChoice = "__prompt__";
92
+ }
93
+ continue;
94
+ }
95
+ if (arg === "--detect-success-criteria") {
96
+ autoDetectSuccessCriteria = true;
97
+ continue;
98
+ }
99
+ if (arg === "--no-detect-success-criteria") {
100
+ autoDetectSuccessCriteria = false;
101
+ continue;
102
+ }
103
+ ideaParts.push(arg);
104
+ }
105
+
106
+ function printHelp() {
107
+ process.stdout.write(
108
+ `\n${colors.cyan('ralph-codex plan "<idea>" [options]')}\n\n` +
109
+ `${colors.yellow("Options:")}\n` +
110
+ ` ${colors.green("--output <path>")} Write tasks to a custom file (alias of --tasks)\n` +
111
+ ` ${colors.green("--tasks <path>")} Write tasks to a custom file (default: tasks.md)\n` +
112
+ ` ${colors.green("--max-iterations <n>")} Max planning iterations (default: 1)\n` +
113
+ ` ${colors.green("--config <path>")} Path to ralph.config.yml\n` +
114
+ ` ${colors.green("--model <name>, -m")} Codex model\n` +
115
+ ` ${colors.green("--profile <name>, -p")} Codex CLI profile\n` +
116
+ ` ${colors.green("--sandbox <mode>")} read-only | workspace-write | danger-full-access\n` +
117
+ ` ${colors.green("--no-sandbox")} Use danger-full-access\n` +
118
+ ` ${colors.green("--ask-for-approval <mode>")} untrusted | on-failure | on-request | never\n` +
119
+ ` ${colors.green("--full-auto")} workspace-write + on-request\n` +
120
+ ` ${colors.green("--reasoning [effort]")} low | medium | high | xhigh (omit to pick)\n` +
121
+ ` ${colors.green("--detect-success-criteria")} Add auto-detected checks\n` +
122
+ ` ${colors.green("--no-detect-success-criteria")} Disable auto-detect\n` +
123
+ ` ${colors.green("-h, --help")} Show help\n\n`,
124
+ );
125
+ }
126
+
127
+ if (showHelp) {
128
+ printHelp();
129
+ process.exit(0);
130
+ }
131
+
132
+ const idea = ideaParts.join(" ").trim();
133
+
134
+ if (!idea) {
135
+ console.error(
136
+ 'Usage: ralph-codex plan "<idea>" [--output <path>] [--tasks <path>] [--max-iterations <n>]',
137
+ );
138
+ process.exit(1);
139
+ }
140
+
141
+ const promptPath = path.join(agentDir, "ralph-plan-prompt.md");
142
+ function loadConfig(configFilePath) {
143
+ if (!configFilePath) return {};
144
+ if (!fs.existsSync(configFilePath)) return {};
145
+ try {
146
+ const content = fs.readFileSync(configFilePath, "utf8");
147
+ return yaml.load(content) || {};
148
+ } catch (error) {
149
+ console.error(
150
+ `Failed to read config at ${configFilePath}: ${error?.message || error}`,
151
+ );
152
+ process.exit(1);
153
+ }
154
+ }
155
+
156
+ function resolveDockerConfig(config) {
157
+ const dockerConfig = config?.docker || {};
158
+ return {
159
+ enabled: Boolean(dockerConfig.enabled),
160
+ dockerfile: dockerConfig.dockerfile || "Dockerfile.ralph",
161
+ image: dockerConfig.image || "ralph-runner",
162
+ baseImage: dockerConfig.base_image || "node:20-bullseye",
163
+ workdir: dockerConfig.workdir || "/workspace",
164
+ codexInstall: dockerConfig.codex_install || "",
165
+ codexHome: dockerConfig.codex_home || ".ralph/codex",
166
+ mountCodexConfig: dockerConfig.mount_codex_config !== false,
167
+ passEnv: Array.isArray(dockerConfig.pass_env) ? dockerConfig.pass_env : [],
168
+ aptPackages: Array.isArray(dockerConfig.apt_packages)
169
+ ? dockerConfig.apt_packages
170
+ : [],
171
+ npmGlobals: Array.isArray(dockerConfig.npm_globals)
172
+ ? dockerConfig.npm_globals
173
+ : [],
174
+ pipPackages: Array.isArray(dockerConfig.pip_packages)
175
+ ? dockerConfig.pip_packages
176
+ : [],
177
+ useForPlan: Boolean(dockerConfig.use_for_plan),
178
+ tty: dockerConfig.tty ?? "auto",
179
+ };
180
+ }
181
+
182
+ function parseRequiredTools(tasksFilePath) {
183
+ if (!fs.existsSync(tasksFilePath)) {
184
+ return { apt: [], npm: [], pip: [] };
185
+ }
186
+ const content = fs.readFileSync(tasksFilePath, "utf8");
187
+ const lines = content.split(/\r?\n/);
188
+ let inSection = false;
189
+ const tools = { apt: [], npm: [], pip: [] };
190
+
191
+ for (const raw of lines) {
192
+ const line = raw.trim();
193
+ if (!line) continue;
194
+ if (
195
+ /^#+\s*required tools\b/i.test(line) ||
196
+ /^required tools\b/i.test(line)
197
+ ) {
198
+ inSection = true;
199
+ continue;
200
+ }
201
+ if (inSection && /^#+\s+/.test(line)) {
202
+ break;
203
+ }
204
+ if (!inSection) continue;
205
+
206
+ const match = line.match(/^-+\s*(apt|npm|pip)\s*:\s*(.*)$/i);
207
+ if (!match) continue;
208
+ const kind = match[1].toLowerCase();
209
+ const items = match[2]
210
+ .split(",")
211
+ .map((item) => item.trim())
212
+ .filter((item) => item && item.toLowerCase() !== "none");
213
+ tools[kind] = tools[kind].concat(items);
214
+ }
215
+
216
+ return tools;
217
+ }
218
+
219
+ function uniqueList(values) {
220
+ return Array.from(new Set(values.filter(Boolean)));
221
+ }
222
+
223
+ function normalizeReasoningEffort(value) {
224
+ if (value === null || value === undefined) return null;
225
+ const trimmed = String(value).trim();
226
+ if (!trimmed) return null;
227
+ const lowered = trimmed.toLowerCase();
228
+ if (["null", "unset", "none", "default"].includes(lowered)) return null;
229
+ if (lowered === "extra-high" || lowered === "extra_high") return "xhigh";
230
+ if (["low", "medium", "high", "xhigh"].includes(lowered)) return lowered;
231
+ return trimmed;
232
+ }
233
+
234
+ async function promptReasoningEffort(currentValue) {
235
+ const choices = [
236
+ {
237
+ name: "unset",
238
+ message: "unset (null)",
239
+ value: null,
240
+ hint: "Use the Codex default",
241
+ },
242
+ {
243
+ name: "low",
244
+ message: "low",
245
+ value: "low",
246
+ hint: "Faster, less thorough reasoning.",
247
+ },
248
+ {
249
+ name: "medium",
250
+ message: "medium",
251
+ value: "medium",
252
+ hint: "Default balance of speed + depth.",
253
+ },
254
+ {
255
+ name: "high",
256
+ message: "high",
257
+ value: "high",
258
+ hint: "Deeper reasoning, slower.",
259
+ },
260
+ {
261
+ name: "xhigh",
262
+ message: "xhigh",
263
+ value: "xhigh",
264
+ hint: "Maximum depth, slowest.",
265
+ },
266
+ ];
267
+ const normalized = normalizeReasoningEffort(currentValue) || "medium";
268
+ const initial = Math.max(
269
+ 0,
270
+ choices.findIndex((choice) => choice.value === normalized)
271
+ );
272
+ const prompt = new AutoComplete({
273
+ name: "reasoning",
274
+ message: "Select model reasoning effort:",
275
+ choices,
276
+ initial,
277
+ limit: Math.min(choices.length, 7),
278
+ });
279
+ return prompt.run();
280
+ }
281
+
282
+ function normalizeChoiceList(value) {
283
+ if (!Array.isArray(value)) return null;
284
+ const cleaned = value.map((item) => String(item).trim()).filter(Boolean);
285
+ return cleaned.length > 0 ? cleaned : null;
286
+ }
287
+
288
+ function safeReadFile(filePath) {
289
+ try {
290
+ return fs.readFileSync(filePath, "utf8");
291
+ } catch (_) {
292
+ return "";
293
+ }
294
+ }
295
+
296
+ function fileExists(relativePath) {
297
+ return fs.existsSync(path.join(root, relativePath));
298
+ }
299
+
300
+ function fileContains(relativePath, snippet) {
301
+ const content = safeReadFile(path.join(root, relativePath));
302
+ return content.includes(snippet);
303
+ }
304
+
305
+ function detectPackageManager() {
306
+ if (fileExists("pnpm-lock.yaml")) return "pnpm";
307
+ if (fileExists("yarn.lock")) return "yarn";
308
+ if (fileExists("bun.lockb")) return "bun";
309
+ return "npm";
310
+ }
311
+
312
+ function buildScriptCommand(script, manager) {
313
+ if (manager === "yarn") return `yarn ${script}`;
314
+ if (manager === "pnpm") return `pnpm ${script}`;
315
+ if (manager === "bun") return `bun run ${script}`;
316
+ return `npm run ${script}`;
317
+ }
318
+
319
+ function detectNodeSuccessCriteria() {
320
+ if (!fileExists("package.json")) return [];
321
+ let pkg = null;
322
+ try {
323
+ pkg = JSON.parse(safeReadFile(path.join(root, "package.json")));
324
+ } catch (_) {
325
+ return [];
326
+ }
327
+ const scripts = pkg && typeof pkg === "object" ? pkg.scripts : null;
328
+ if (!scripts || typeof scripts !== "object") return [];
329
+ const scriptNames = Object.keys(scripts).filter(
330
+ (name) => name && !name.startsWith("pre") && !name.startsWith("post"),
331
+ );
332
+ if (scriptNames.length === 0) return [];
333
+ const preferred = [
334
+ "test",
335
+ "test:unit",
336
+ "test:integration",
337
+ "test:e2e",
338
+ "e2e",
339
+ "lint",
340
+ "typecheck",
341
+ "build",
342
+ "ci",
343
+ "check",
344
+ "format",
345
+ ];
346
+ const manager = detectPackageManager();
347
+ const picked = preferred.filter((name) => scriptNames.includes(name));
348
+ const commands = picked.map((name) => buildScriptCommand(name, manager));
349
+ if (commands.length > 0) return commands;
350
+ const keywordMatches = scriptNames.filter((name) =>
351
+ /(test|lint|build|typecheck|check|ci|e2e)/.test(name),
352
+ );
353
+ return keywordMatches
354
+ .slice(0, 6)
355
+ .map((name) => buildScriptCommand(name, manager));
356
+ }
357
+
358
+ function detectMakeTargets() {
359
+ const makefile = ["Makefile", "makefile"].find((name) => fileExists(name));
360
+ if (!makefile) return [];
361
+ const content = safeReadFile(path.join(root, makefile));
362
+ if (!content) return [];
363
+ const targets = new Set();
364
+ for (const raw of content.split(/\r?\n/)) {
365
+ if (!raw || /^\s/.test(raw)) continue;
366
+ const match = raw.match(/^([A-Za-z0-9][A-Za-z0-9_./-]*)\s*:/);
367
+ if (!match) continue;
368
+ const target = match[1].trim();
369
+ if (!target || target.startsWith(".") || target.includes("%")) continue;
370
+ targets.add(target);
371
+ }
372
+ const preferred = ["test", "lint", "build", "check", "ci", "fmt", "format"];
373
+ return preferred
374
+ .filter((target) => targets.has(target))
375
+ .map((target) => `make ${target}`);
376
+ }
377
+
378
+ function detectPythonSuccessCriteria() {
379
+ const hasPython =
380
+ fileExists("pyproject.toml") ||
381
+ fileExists("requirements.txt") ||
382
+ fileExists("setup.py") ||
383
+ fileExists("setup.cfg");
384
+ if (!hasPython) return [];
385
+ const hasPytestConfig =
386
+ fileExists("pytest.ini") ||
387
+ fileContains("pyproject.toml", "[tool.pytest") ||
388
+ fileContains("setup.cfg", "[tool:pytest]");
389
+ const hasTestsDir =
390
+ fs.existsSync(path.join(root, "tests")) ||
391
+ fs.existsSync(path.join(root, "test"));
392
+ const choices = [];
393
+ if (fileExists("tox.ini")) choices.push("tox");
394
+ if (fileExists("noxfile.py")) choices.push("nox");
395
+ if (hasPytestConfig || hasTestsDir) choices.push("python -m pytest");
396
+ return choices;
397
+ }
398
+
399
+ function detectGoSuccessCriteria() {
400
+ if (fileExists("go.mod") || fileExists("go.work")) {
401
+ return ["go test ./..."];
402
+ }
403
+ return [];
404
+ }
405
+
406
+ function detectRustSuccessCriteria() {
407
+ if (fileExists("Cargo.toml")) return ["cargo test"];
408
+ return [];
409
+ }
410
+
411
+ function detectJavaSuccessCriteria() {
412
+ const choices = [];
413
+ if (fileExists("pom.xml")) choices.push("mvn test");
414
+ if (fileExists("build.gradle") || fileExists("build.gradle.kts")) {
415
+ choices.push(fileExists("gradlew") ? "./gradlew test" : "gradle test");
416
+ }
417
+ return choices;
418
+ }
419
+
420
+ function detectDotNetSuccessCriteria() {
421
+ let entries = [];
422
+ try {
423
+ entries = fs.readdirSync(root, { withFileTypes: true });
424
+ } catch (_) {
425
+ return [];
426
+ }
427
+ const hasDotNet = entries.some(
428
+ (entry) =>
429
+ entry.isFile() &&
430
+ (entry.name.endsWith(".sln") ||
431
+ entry.name.endsWith(".csproj") ||
432
+ entry.name.endsWith(".fsproj") ||
433
+ entry.name.endsWith(".vbproj")),
434
+ );
435
+ return hasDotNet ? ["dotnet test"] : [];
436
+ }
437
+
438
+ function detectSuccessCriteria() {
439
+ return uniqueList([
440
+ ...detectNodeSuccessCriteria(),
441
+ ...detectMakeTargets(),
442
+ ...detectPythonSuccessCriteria(),
443
+ ...detectGoSuccessCriteria(),
444
+ ...detectRustSuccessCriteria(),
445
+ ...detectJavaSuccessCriteria(),
446
+ ...detectDotNetSuccessCriteria(),
447
+ ]);
448
+ }
449
+
450
+ function extractRequiredToolsSection(content) {
451
+ const lines = content.split(/\r?\n/);
452
+ let start = -1;
453
+ let end = lines.length;
454
+ for (let i = 0; i < lines.length; i += 1) {
455
+ const line = lines[i].trim();
456
+ if (/^(#+\s*)?required tools\b/i.test(line)) {
457
+ start = i;
458
+ break;
459
+ }
460
+ }
461
+ if (start !== -1) {
462
+ for (let i = start + 1; i < lines.length; i += 1) {
463
+ if (/^#+\s+/.test(lines[i].trim())) {
464
+ end = i;
465
+ break;
466
+ }
467
+ }
468
+ }
469
+
470
+ const tools = { apt: [], npm: [], pip: [] };
471
+ if (start !== -1) {
472
+ for (let i = start + 1; i < end; i += 1) {
473
+ const line = lines[i].trim();
474
+ const match = line.match(/^-+\s*(apt|npm|pip)\s*:\s*(.*)$/i);
475
+ if (!match) continue;
476
+ const kind = match[1].toLowerCase();
477
+ const items = match[2]
478
+ .split(",")
479
+ .map((item) => item.trim())
480
+ .filter((item) => item && item.toLowerCase() !== "none");
481
+ tools[kind] = items;
482
+ }
483
+ }
484
+
485
+ return { lines, start, end, tools };
486
+ }
487
+
488
+ function inferRequiredTools(content) {
489
+ const text = content.toLowerCase();
490
+ const tools = { apt: [], npm: [], pip: [] };
491
+ const add = (bucket, item) => {
492
+ if (!item) return;
493
+ tools[bucket].push(item);
494
+ };
495
+
496
+ if (/\bffmpeg\b|\bffprobe\b/.test(text)) add("apt", "ffmpeg");
497
+ if (/\bimagemagick\b|\bconvert\b/.test(text)) add("apt", "imagemagick");
498
+ if (/\bpillow\b/.test(text)) add("pip", "pillow");
499
+ if (/\bpython\b/.test(text)) add("apt", "python3");
500
+ if (/\bcurl\b/.test(text)) add("apt", "curl");
501
+ if (/\bwget\b/.test(text)) add("apt", "wget");
502
+ if (/\bplaywright\b/.test(text)) add("npm", "playwright");
503
+ if (/\bsharp\b/.test(text)) add("npm", "sharp");
504
+ if (/\bchromium\b|\bchrome\b/.test(text)) add("apt", "chromium");
505
+
506
+ tools.apt = uniqueList(tools.apt);
507
+ tools.npm = uniqueList(tools.npm);
508
+ tools.pip = uniqueList(tools.pip);
509
+ return tools;
510
+ }
511
+
512
+ function enrichRequiredTools(tasksFilePath) {
513
+ if (!fs.existsSync(tasksFilePath)) return;
514
+ const content = fs.readFileSync(tasksFilePath, "utf8");
515
+ const {
516
+ lines,
517
+ start,
518
+ end,
519
+ tools: existing,
520
+ } = extractRequiredToolsSection(content);
521
+ const inferred = inferRequiredTools(content);
522
+
523
+ const merged = {
524
+ apt: uniqueList([...existing.apt, ...inferred.apt]),
525
+ npm: uniqueList([...existing.npm, ...inferred.npm]),
526
+ pip: uniqueList([...existing.pip, ...inferred.pip]),
527
+ };
528
+
529
+ const sectionLines = [
530
+ "## Required tools",
531
+ `- apt: ${merged.apt.length ? merged.apt.join(", ") : "none"}`,
532
+ `- npm: ${merged.npm.length ? merged.npm.join(", ") : "none"}`,
533
+ `- pip: ${merged.pip.length ? merged.pip.join(", ") : "none"}`,
534
+ ];
535
+
536
+ let updatedLines = lines.slice();
537
+ if (start === -1) {
538
+ const successIndex = updatedLines.findIndex((line) =>
539
+ /^##\s*success criteria\b/i.test(line.trim()),
540
+ );
541
+ if (successIndex !== -1) {
542
+ let insertAt = updatedLines.length;
543
+ for (let i = successIndex + 1; i < updatedLines.length; i += 1) {
544
+ if (/^#+\s+/.test(updatedLines[i].trim())) {
545
+ insertAt = i;
546
+ break;
547
+ }
548
+ }
549
+ updatedLines.splice(insertAt, 0, "", ...sectionLines);
550
+ } else {
551
+ updatedLines = updatedLines.concat(["", ...sectionLines]);
552
+ }
553
+ } else {
554
+ updatedLines.splice(start, end - start, ...sectionLines);
555
+ }
556
+
557
+ const updated = updatedLines.join("\n");
558
+ if (updated !== content) {
559
+ fs.writeFileSync(tasksFilePath, updated, "utf8");
560
+ }
561
+ }
562
+
563
+ function ensureDockerfile(dockerConfig, requiredTools) {
564
+ if (!dockerConfig.enabled) return;
565
+ const dockerfilePath = path.join(root, dockerConfig.dockerfile);
566
+ const tools = requiredTools || { apt: [], npm: [], pip: [] };
567
+ const aptPackages = uniqueList([...dockerConfig.aptPackages, ...tools.apt]);
568
+ const npmGlobals = uniqueList([...dockerConfig.npmGlobals, ...tools.npm]);
569
+ const pipPackages = uniqueList([...dockerConfig.pipPackages, ...tools.pip]);
570
+
571
+ if (pipPackages.length > 0) {
572
+ if (!aptPackages.includes("python3")) aptPackages.push("python3");
573
+ if (!aptPackages.includes("python3-venv")) aptPackages.push("python3-venv");
574
+ if (!aptPackages.includes("python3-pip")) aptPackages.push("python3-pip");
575
+ }
576
+
577
+ const lines = [
578
+ "# Generated by ralph-codex plan",
579
+ `FROM ${dockerConfig.baseImage}`,
580
+ aptPackages.length > 0
581
+ ? `RUN apt-get update && apt-get install -y ${aptPackages.join(
582
+ " ",
583
+ )} && rm -rf /var/lib/apt/lists/*`
584
+ : "RUN apt-get update && rm -rf /var/lib/apt/lists/*",
585
+ `WORKDIR ${dockerConfig.workdir}`,
586
+ ];
587
+
588
+ if (dockerConfig.codexInstall) {
589
+ lines.push(`RUN ${dockerConfig.codexInstall}`);
590
+ } else {
591
+ lines.push("# TODO: set docker.codex_install in ralph.config.yml");
592
+ }
593
+
594
+ if (npmGlobals.length > 0) {
595
+ lines.push(`RUN npm install -g ${npmGlobals.join(" ")}`);
596
+ }
597
+ if (pipPackages.length > 0) {
598
+ lines.push("RUN python3 -m venv /opt/venv");
599
+ lines.push('ENV PATH="/opt/venv/bin:$PATH"');
600
+ lines.push(`RUN pip install --no-cache-dir ${pipPackages.join(" ")}`);
601
+ }
602
+
603
+ fs.writeFileSync(dockerfilePath, `${lines.join("\n")}\n`, "utf8");
604
+ }
605
+
606
+ let dockerBuilt = false;
607
+
608
+ function ensureDockerImage(dockerConfig) {
609
+ if (!dockerConfig.enabled || dockerBuilt) return;
610
+ ensureDockerfile(dockerConfig);
611
+ const dockerfilePath = path.join(root, dockerConfig.dockerfile);
612
+ const result = spawnSync(
613
+ "docker",
614
+ ["build", "-f", dockerfilePath, "-t", dockerConfig.image, "."],
615
+ { stdio: "inherit", cwd: root },
616
+ );
617
+ if (result.status !== 0) {
618
+ process.exit(result.status ?? 1);
619
+ }
620
+ dockerBuilt = true;
621
+ }
622
+
623
+ function buildDockerRunArgs(dockerConfig) {
624
+ const args = ["run", "--rm", "-i"];
625
+ const wantsTty =
626
+ dockerConfig.tty === true ||
627
+ dockerConfig.tty === "true" ||
628
+ (dockerConfig.tty === "auto" && process.stdin.isTTY);
629
+ if (wantsTty) args.push("-t");
630
+ args.push(
631
+ "-v",
632
+ `${root}:${dockerConfig.workdir}`,
633
+ "-w",
634
+ dockerConfig.workdir,
635
+ );
636
+ const codexHome = path.isAbsolute(dockerConfig.codexHome)
637
+ ? dockerConfig.codexHome
638
+ : path.join(root, dockerConfig.codexHome);
639
+ fs.mkdirSync(codexHome, { recursive: true });
640
+ if (dockerConfig.mountCodexConfig) {
641
+ const hostCodex = path.join(os.homedir(), ".codex");
642
+ if (fs.existsSync(hostCodex)) {
643
+ const existing = fs.readdirSync(codexHome);
644
+ if (existing.length === 0) {
645
+ fs.cpSync(hostCodex, codexHome, { recursive: true });
646
+ }
647
+ }
648
+ }
649
+ args.push("-v", `${codexHome}:/root/.codex`, "-e", "HOME=/root");
650
+
651
+ for (const envName of dockerConfig.passEnv) {
652
+ const value = process.env[envName];
653
+ if (value) args.push("-e", `${envName}=${value}`);
654
+ }
655
+
656
+ return args;
657
+ }
658
+
659
+ function buildPrompt({ successCriteria, autoCriteria }) {
660
+ const successCriteriaBlock = autoCriteria
661
+ ? `- Include a short "Success criteria" section with 3-6 items derived from the tasks you propose and this repo.
662
+ - Prefer real commands you can infer from project files (package.json scripts, Makefile targets, pyproject tools, etc.).
663
+ - If no reliable command exists, include 1 manual verification check tied to the main flow.`
664
+ : `- Include a short "Success criteria" section with exactly these items (commands or checks):
665
+ ${successCriteria}`;
666
+ return `# Ralph Plan
667
+
668
+ You are creating a task list for this repo.
669
+
670
+ Idea:
671
+ ${idea}
672
+
673
+ Context scan (read-only):
674
+ - Quickly inspect top-level files to understand stack and conventions: README, package.json,
675
+ pyproject.toml, requirements.txt, go.mod, Cargo.toml, pom.xml, build.gradle, Makefile,
676
+ .nvmrc, Dockerfile, etc. Only inspect files that exist.
677
+ - Use this context to infer file locations, tooling, and sensible commands.
678
+
679
+ Requirements:
680
+ - If there are open questions, ask them first and do not write ${tasksPath}.
681
+ - Only ask a single round of questions, then stop and output: LOOP_COMPLETE.
682
+ - Ask only what blocks concrete task creation. Prefer 3-6 precise, technical questions.
683
+ - Consolidate related details into a single question (avoid duplicates or rephrasing).
684
+ - Do not ask for info already provided in the idea or prior answers.
685
+ - If details are missing but non-blocking, make a reasonable default and record it under
686
+ an "Assumptions" section in ${tasksPath}.
687
+ - Use numbered questions. Make each question specific (route, env, file paths, commands).
688
+ - If answers are provided, do not ask more questions. Make reasonable assumptions
689
+ and proceed to write ${tasksPath}.
690
+ - If "Revision feedback" is provided, update ${tasksPath} accordingly without
691
+ asking new questions.
692
+ - Output a Markdown task list to ${tasksPath} using \`- [ ]\` checkboxes.
693
+ - Tasks must be atomic, ordered, and verifiable. Include exact file paths,
694
+ commands to run (if any), and expected outcomes. Avoid vague verbs like "handle" or "improve".
695
+ - Keep scope minimal: avoid refactors unless required by the idea or to unblock tasks.
696
+ ${successCriteriaBlock}
697
+ - Include a "Required tools" section using this exact format:
698
+ - \`- apt: <comma-separated packages or none>\`
699
+ - \`- npm: <comma-separated packages or none>\`
700
+ - \`- pip: <comma-separated packages or none>\`
701
+ - Do not edit any files other than ${tasksPath}.
702
+ - Do not run commands, tests, or start dev servers during planning.
703
+
704
+ When done, output exactly: LOOP_COMPLETE
705
+ `;
706
+ }
707
+
708
+ async function runCodex(prompt, spinnerText) {
709
+ fs.writeFileSync(promptPath, prompt, "utf8");
710
+ const args = ["exec"];
711
+
712
+ if (model) args.push("--model", model);
713
+ if (profile) args.push("--profile", profile);
714
+ if (fullAuto) args.push("--full-auto");
715
+ if (askForApproval) {
716
+ args.push("--config", `ask_for_approval=${askForApproval}`);
717
+ }
718
+ if (modelReasoningEffort) {
719
+ args.push("--config", `model_reasoning_effort=${modelReasoningEffort}`);
720
+ }
721
+
722
+ const resolvedSandbox = noSandbox ? "danger-full-access" : sandbox;
723
+ if (resolvedSandbox) args.push("--sandbox", resolvedSandbox);
724
+
725
+ args.push("-");
726
+
727
+ let command = "codex";
728
+ let commandArgs = args;
729
+ if (activeDockerConfig?.enabled) {
730
+ if (!activeDockerConfig.codexInstall) {
731
+ console.error(
732
+ "docker.codex_install is required when docker.enabled is true.",
733
+ );
734
+ process.exit(1);
735
+ }
736
+ ensureDockerImage(activeDockerConfig);
737
+ command = "docker";
738
+ commandArgs = [
739
+ ...buildDockerRunArgs(activeDockerConfig),
740
+ activeDockerConfig.image,
741
+ "codex",
742
+ ...args,
743
+ ];
744
+ }
745
+
746
+ const spinner = createSpinner(spinnerText || "Generating plan...");
747
+ return new Promise((resolve) => {
748
+ const styler = createLogStyler();
749
+ const child = spawn(command, commandArgs, {
750
+ cwd: root,
751
+ env: process.env,
752
+ stdio: ["pipe", "pipe", "pipe"],
753
+ });
754
+ let stdout = "";
755
+ let stderr = "";
756
+ child.stdout.on("data", (data) => {
757
+ stdout += data.toString();
758
+ });
759
+ child.stderr.on("data", (data) => {
760
+ stderr += data.toString();
761
+ });
762
+ child.on("error", (error) => {
763
+ spinner.stop();
764
+ console.error(error?.message || error);
765
+ process.exit(1);
766
+ });
767
+ child.on("close", (code) => {
768
+ spinner.stop();
769
+ const combined = `${stdout}\n${stderr}`;
770
+ const lines = combined.split(/\r?\n/);
771
+ for (const line of lines) {
772
+ if (!line) continue;
773
+ process.stdout.write(`${styler.formatLine(line)}\n`);
774
+ }
775
+ resolve({ status: code ?? 0, output: combined.trim() });
776
+ });
777
+ child.stdin.write(prompt);
778
+ child.stdin.end();
779
+ });
780
+ }
781
+
782
+ function resetStdin() {
783
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
784
+ process.stdin.setRawMode(false);
785
+ }
786
+ process.stdin.resume();
787
+ }
788
+
789
+ function extractQuestions(output) {
790
+ const lines = output.split(/\r?\n/);
791
+ const questions = [];
792
+ let inQuestions = false;
793
+
794
+ for (const raw of lines) {
795
+ const line = raw.trim();
796
+ if (!line) continue;
797
+ if (/^#+\s*questions/i.test(line) || /^questions[:]?/i.test(line)) {
798
+ inQuestions = true;
799
+ continue;
800
+ }
801
+
802
+ if (inQuestions && !line.startsWith("-") && !line.match(/^\d+[.)]/)) {
803
+ // Stop on first non-question line once in the questions section.
804
+ inQuestions = false;
805
+ }
806
+
807
+ const isQuestion =
808
+ line.endsWith("?") || (inQuestions && line.includes("?"));
809
+
810
+ if (isQuestion) {
811
+ const cleaned = line.replace(/^[-*\d.)\s]+/, "").trim();
812
+ if (cleaned) questions.push(cleaned);
813
+ }
814
+ }
815
+
816
+ return Array.from(new Set(questions));
817
+ }
818
+
819
+ async function readUserAnswers(questions) {
820
+ resetStdin();
821
+ if (questions && questions.length > 0) {
822
+ const answers = [];
823
+ for (const question of questions) {
824
+ const input = new Input({
825
+ name: "answer",
826
+ message: question,
827
+ });
828
+ const answer = await input.run();
829
+ answers.push(`Q: ${question}\nA: ${answer}`);
830
+ }
831
+ return answers.join("\n\n");
832
+ }
833
+
834
+ if (process.stdin.isTTY) {
835
+ try {
836
+ const editor = new Editor({
837
+ name: "answers",
838
+ message:
839
+ "Provide your answers in the editor, then save and close to continue.",
840
+ });
841
+ return await editor.run();
842
+ } catch (_) {
843
+ // Fall through to stdin prompt if editor is unavailable.
844
+ }
845
+ }
846
+
847
+ const input = new Input({
848
+ name: "answers",
849
+ message: "Provide your answers (single line):",
850
+ });
851
+ return input.run();
852
+ }
853
+
854
+ async function selectSuccessCriteria(
855
+ defaultCriteria,
856
+ standardChoices,
857
+ detectedChoices = []
858
+ ) {
859
+ const autoChoiceValue = "__auto__";
860
+ const customChoiceValue = "__custom__";
861
+ const defaults =
862
+ defaultCriteria && defaultCriteria.length > 0
863
+ ? defaultCriteria
864
+ : standardChoices;
865
+ const extras = (defaultCriteria || []).filter(
866
+ (item) => !standardChoices.includes(item)
867
+ );
868
+ const baseChoices = uniqueList([...standardChoices, ...extras]);
869
+ const detectedSet = new Set(detectedChoices || []);
870
+ const extraSet = new Set(extras);
871
+ const choices = [
872
+ {
873
+ name: autoChoiceValue,
874
+ message: "Ask Codex to choose",
875
+ value: autoChoiceValue,
876
+ hint: "Let Codex derive success criteria from tasks + repo.",
877
+ },
878
+ ...baseChoices.map((choice) => {
879
+ let hint = "recommended";
880
+ if (detectedSet.has(choice)) hint = "detected";
881
+ if (extraSet.has(choice)) hint = "from config";
882
+ return {
883
+ name: choice,
884
+ message: choice,
885
+ value: choice,
886
+ hint,
887
+ };
888
+ }),
889
+ ];
890
+ choices.push({
891
+ name: customChoiceValue,
892
+ message: "Add custom command(s)",
893
+ value: customChoiceValue,
894
+ hint: "Enter your own commands.",
895
+ });
896
+
897
+ const prompt = new MultiSelect({
898
+ name: "criteria",
899
+ message: "Select completion checks (space to toggle, enter to confirm):",
900
+ choices,
901
+ initial: choices
902
+ .map((choice, index) =>
903
+ defaults.includes(choice.value) ? index : null
904
+ )
905
+ .filter((index) => index !== null),
906
+ });
907
+
908
+ let selected = await prompt.run();
909
+ if (!selected || selected.length === 0) {
910
+ throw new Error("You must select at least one completion check.");
911
+ }
912
+
913
+ const wantsAuto = selected.includes(autoChoiceValue);
914
+ selected = selected.filter((item) => item !== autoChoiceValue);
915
+ if (wantsAuto) {
916
+ if (selected.length > 0) {
917
+ process.stdout.write(
918
+ `${colors.yellow(
919
+ 'Note: "Ask Codex to choose" selected; ignoring other choices.'
920
+ )}\n`
921
+ );
922
+ }
923
+ return { mode: "auto", criteria: [] };
924
+ }
925
+
926
+ const wantsCustom = selected.includes(customChoiceValue);
927
+ selected = selected.filter((item) => item !== customChoiceValue);
928
+
929
+ if (wantsCustom) {
930
+ const input = await new Input({
931
+ name: "custom",
932
+ message:
933
+ "Enter custom commands (comma-separated), e.g. make test, pytest, go test ./...:",
934
+ }).run();
935
+
936
+ const extras = input
937
+ .split(",")
938
+ .map((item) => item.trim())
939
+ .filter(Boolean);
940
+
941
+ selected = [...selected, ...extras];
942
+ }
943
+
944
+ if (selected.length === 0) {
945
+ throw new Error("You must select at least one completion check.");
946
+ }
947
+
948
+ return { mode: "manual", criteria: selected };
949
+ }
950
+
951
+ async function confirmPlan(tasksFile) {
952
+ const content = fs.readFileSync(tasksFile, "utf8");
953
+ process.stdout.write(`\n${colors.cyan("--- Proposed tasks.md ---")}\n\n`);
954
+ process.stdout.write(content);
955
+ process.stdout.write(`\n${colors.cyan("--- End tasks.md ---")}\n\n`);
956
+ const confirm = new Confirm({
957
+ name: "confirm",
958
+ message: "Approve this plan?",
959
+ initial: true,
960
+ });
961
+ return confirm.run();
962
+ }
963
+
964
+ async function readRevisionFeedback() {
965
+ resetStdin();
966
+ const input = new Input({
967
+ name: "feedback",
968
+ message:
969
+ "Enter revision feedback (single line). Use ';' to separate items:",
970
+ });
971
+ return input.run();
972
+ }
973
+
974
+ async function confirmResetState(tasksFilePath, agentPath) {
975
+ const hasTasks = fs.existsSync(tasksFilePath);
976
+ const hasAgent = fs.existsSync(agentPath);
977
+ if (!hasTasks && !hasAgent) return false;
978
+
979
+ const confirm = new Confirm({
980
+ name: "reset",
981
+ message: "Reset existing plan state (tasks.md + .ralph)?",
982
+ initial: true,
983
+ });
984
+ return confirm.run();
985
+ }
986
+
987
+ function resetState(tasksFilePath, agentPath) {
988
+ if (fs.existsSync(tasksFilePath)) {
989
+ fs.rmSync(tasksFilePath, { force: true });
990
+ }
991
+ if (fs.existsSync(agentPath)) {
992
+ fs.rmSync(agentPath, { recursive: true, force: true });
993
+ }
994
+ }
995
+
996
+ async function main() {
997
+ const resolvedConfigPath = configPath || path.join(root, "ralph.config.yml");
998
+ const config = loadConfig(resolvedConfigPath);
999
+ const codexConfig = config?.codex || {};
1000
+ const planConfig = config?.plan || {};
1001
+ const dockerConfig = resolveDockerConfig(config);
1002
+ activeDockerConfig = dockerConfig.useForPlan ? dockerConfig : null;
1003
+
1004
+ if (!model && codexConfig.model) model = codexConfig.model;
1005
+ if (!profile && codexConfig.profile) profile = codexConfig.profile;
1006
+ if (!sandbox && codexConfig.sandbox) sandbox = codexConfig.sandbox;
1007
+ if (!askForApproval && codexConfig.ask_for_approval) {
1008
+ askForApproval = codexConfig.ask_for_approval;
1009
+ }
1010
+ if (!fullAuto && codexConfig.full_auto) fullAuto = true;
1011
+ if (!modelReasoningEffort && codexConfig.model_reasoning_effort) {
1012
+ modelReasoningEffort = codexConfig.model_reasoning_effort;
1013
+ }
1014
+ if (typeof reasoningChoice !== "undefined") {
1015
+ if (reasoningChoice === "__prompt__") {
1016
+ modelReasoningEffort = await promptReasoningEffort(modelReasoningEffort);
1017
+ } else {
1018
+ modelReasoningEffort = normalizeReasoningEffort(reasoningChoice);
1019
+ }
1020
+ }
1021
+ if (tasksPath === "tasks.md" && planConfig.tasks_path) {
1022
+ tasksPath = planConfig.tasks_path;
1023
+ }
1024
+
1025
+ const tasksFile = path.join(root, tasksPath);
1026
+ const shouldReset = await confirmResetState(tasksFile, agentDir);
1027
+ if (shouldReset) {
1028
+ resetState(tasksFile, agentDir);
1029
+ }
1030
+ fs.mkdirSync(agentDir, { recursive: true });
1031
+
1032
+ let selection = { mode: "manual", criteria: [] };
1033
+ try {
1034
+ const fallbackChoices = [
1035
+ "Run the project's primary test suite",
1036
+ "Run relevant linters or static checks",
1037
+ "Run the project's build or CI checks",
1038
+ "Manual verification of the main flow",
1039
+ ];
1040
+ const autoDetectEnabled =
1041
+ typeof autoDetectSuccessCriteria === "boolean"
1042
+ ? autoDetectSuccessCriteria
1043
+ : Boolean(planConfig.auto_detect_success_criteria);
1044
+ const detectedChoices = autoDetectEnabled ? detectSuccessCriteria() : [];
1045
+ const configuredDefaults = normalizeChoiceList(
1046
+ planConfig.default_success_criteria || config?.run?.success_criteria,
1047
+ );
1048
+ const configuredChoices = normalizeChoiceList(
1049
+ planConfig.success_choices || config?.run?.success_criteria,
1050
+ );
1051
+ const baseChoices = configuredChoices || fallbackChoices;
1052
+ const standardChoices =
1053
+ detectedChoices.length > 0
1054
+ ? uniqueList([...detectedChoices, ...baseChoices])
1055
+ : baseChoices;
1056
+ const defaults =
1057
+ configuredDefaults ||
1058
+ (detectedChoices.length > 0 ? detectedChoices : baseChoices);
1059
+ selection = await selectSuccessCriteria(
1060
+ defaults,
1061
+ standardChoices,
1062
+ detectedChoices
1063
+ );
1064
+ } catch (error) {
1065
+ console.error(error?.message || "Failed to select completion checks.");
1066
+ process.exit(1);
1067
+ }
1068
+
1069
+ const autoCriteria = selection.mode === "auto";
1070
+ const successCriteria = selection.criteria
1071
+ .map((item) => ` - \`${item}\``)
1072
+ .join("\n");
1073
+ const promptBase = buildPrompt({ successCriteria, autoCriteria });
1074
+
1075
+ const first = await runCodex(promptBase, "Generating plan...");
1076
+ const questions = extractQuestions(first.output);
1077
+
1078
+ if (questions.length > 0) {
1079
+ const answers = await readUserAnswers(questions);
1080
+ if (!answers) {
1081
+ console.error("No answers provided. Aborting.");
1082
+ process.exit(1);
1083
+ }
1084
+
1085
+ process.stdout.write("\nRunning plan with your answers...\n\n");
1086
+ const promptWithAnswers = `${promptBase}\nAnswers:\n${answers}\n`;
1087
+ await runCodex(promptWithAnswers, "Generating plan with answers...");
1088
+ enrichRequiredTools(tasksFile);
1089
+ } else {
1090
+ enrichRequiredTools(tasksFile);
1091
+ }
1092
+
1093
+ if (!fs.existsSync(tasksFile)) {
1094
+ console.error(`Planning did not produce ${tasksPath}.`);
1095
+ process.exit(1);
1096
+ }
1097
+
1098
+ // Allow user review loop for the plan.
1099
+ while (true) {
1100
+ const approved = await confirmPlan(tasksFile);
1101
+ if (approved) break;
1102
+
1103
+ const feedback = await readRevisionFeedback();
1104
+ if (!feedback) {
1105
+ console.error("No feedback provided. Aborting.");
1106
+ process.exit(1);
1107
+ }
1108
+
1109
+ process.stdout.write("\nUpdating plan with your feedback...\n\n");
1110
+ const revisionPrompt = `${promptBase}\nRevision feedback:\n${feedback}\n`;
1111
+ await runCodex(revisionPrompt, "Updating plan...");
1112
+ enrichRequiredTools(tasksFile);
1113
+
1114
+ if (!fs.existsSync(tasksFile)) {
1115
+ console.error(`Planning did not produce ${tasksPath}.`);
1116
+ process.exit(1);
1117
+ }
1118
+ }
1119
+
1120
+ if (dockerConfig.enabled) {
1121
+ const requiredTools = parseRequiredTools(tasksFile);
1122
+ ensureDockerfile(dockerConfig, requiredTools);
1123
+ process.stdout.write(
1124
+ `Generated ${dockerConfig.dockerfile} with required tools.\n`,
1125
+ );
1126
+ }
1127
+ }
1128
+
1129
+ void main();