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.
- package/CHANGELOG.md +6 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/bin/ralph-codex.js +56 -0
- package/package.json +49 -0
- package/src/commands/docker.js +174 -0
- package/src/commands/init.js +413 -0
- package/src/commands/plan.js +1129 -0
- package/src/commands/refine.js +1 -0
- package/src/commands/reset.js +105 -0
- package/src/commands/revise.js +571 -0
- package/src/commands/run.js +1225 -0
- package/src/commands/view.js +695 -0
- package/src/ui/terminal.js +137 -0
- package/templates/ralph.config.yml +53 -0
|
@@ -0,0 +1,1225 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "child_process";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import path from "path";
|
|
5
|
+
import yaml from "js-yaml";
|
|
6
|
+
import enquirer from "enquirer";
|
|
7
|
+
import { colors, createLogStyler, createProgressBar } from "../ui/terminal.js";
|
|
8
|
+
|
|
9
|
+
const { AutoComplete, Confirm } = enquirer;
|
|
10
|
+
|
|
11
|
+
const root = process.cwd();
|
|
12
|
+
const agentDir = path.join(root, ".ralph");
|
|
13
|
+
const repoName = path.basename(root);
|
|
14
|
+
|
|
15
|
+
const argv = process.argv.slice(2);
|
|
16
|
+
let maxIterations = 15;
|
|
17
|
+
let maxIterationSeconds = null;
|
|
18
|
+
let maxTotalSeconds = null;
|
|
19
|
+
let quiet = false;
|
|
20
|
+
let tasksPath = "tasks.md";
|
|
21
|
+
let noSandbox = false;
|
|
22
|
+
let sandbox = null;
|
|
23
|
+
let fullAuto = false;
|
|
24
|
+
let askForApproval = null;
|
|
25
|
+
let model = null;
|
|
26
|
+
let profile = null;
|
|
27
|
+
let completionPromise = "LOOP_COMPLETE";
|
|
28
|
+
let stopOnError = false;
|
|
29
|
+
let streamLog = true;
|
|
30
|
+
let configPath = null;
|
|
31
|
+
let modelReasoningEffort = null;
|
|
32
|
+
let activeDockerConfig = null;
|
|
33
|
+
let streamScratchpad = false;
|
|
34
|
+
let reasoningChoice;
|
|
35
|
+
let showHelp = false;
|
|
36
|
+
|
|
37
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
38
|
+
const arg = argv[i];
|
|
39
|
+
if (arg === "--help" || arg === "-h" || arg === "help") {
|
|
40
|
+
showHelp = true;
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (arg === "--max-iterations") {
|
|
44
|
+
maxIterations = Number(argv[i + 1] || 0) || maxIterations;
|
|
45
|
+
i += 1;
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (arg === "--max-iteration-seconds") {
|
|
49
|
+
const value = Number(argv[i + 1] || 0);
|
|
50
|
+
if (Number.isFinite(value) && value > 0) {
|
|
51
|
+
maxIterationSeconds = value;
|
|
52
|
+
}
|
|
53
|
+
i += 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === "--max-total-seconds") {
|
|
57
|
+
const value = Number(argv[i + 1] || 0);
|
|
58
|
+
if (Number.isFinite(value) && value > 0) {
|
|
59
|
+
maxTotalSeconds = value;
|
|
60
|
+
}
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (arg === "--tasks") {
|
|
65
|
+
tasksPath = argv[i + 1];
|
|
66
|
+
i += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (arg === "--input") {
|
|
70
|
+
tasksPath = argv[i + 1];
|
|
71
|
+
i += 1;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (arg === "--quiet" || arg === "-q") {
|
|
75
|
+
quiet = true;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (arg === "--no-sandbox") {
|
|
79
|
+
noSandbox = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === "--sandbox") {
|
|
83
|
+
sandbox = argv[i + 1];
|
|
84
|
+
i += 1;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === "--full-auto") {
|
|
88
|
+
fullAuto = true;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === "--ask-for-approval") {
|
|
92
|
+
askForApproval = argv[i + 1];
|
|
93
|
+
i += 1;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg === "--model" || arg === "-m") {
|
|
97
|
+
model = argv[i + 1];
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg === "--profile" || arg === "-p") {
|
|
102
|
+
profile = argv[i + 1];
|
|
103
|
+
i += 1;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (arg === "--reasoning") {
|
|
107
|
+
const next = argv[i + 1];
|
|
108
|
+
if (next && !next.startsWith("-")) {
|
|
109
|
+
reasoningChoice = next;
|
|
110
|
+
i += 1;
|
|
111
|
+
} else {
|
|
112
|
+
reasoningChoice = "__prompt__";
|
|
113
|
+
}
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (arg === "--config") {
|
|
117
|
+
configPath = argv[i + 1];
|
|
118
|
+
i += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (arg === "--completion-promise") {
|
|
122
|
+
completionPromise = argv[i + 1] || completionPromise;
|
|
123
|
+
i += 1;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg === "--stop-on-error") {
|
|
127
|
+
stopOnError = true;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (arg === "--no-log-stream") {
|
|
131
|
+
streamLog = false;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (arg === "--tail-log") {
|
|
135
|
+
streamLog = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (arg === "--tail-scratchpad") {
|
|
139
|
+
streamScratchpad = true;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (arg === "--no-tail") {
|
|
143
|
+
streamLog = false;
|
|
144
|
+
streamScratchpad = false;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function printHelp() {
|
|
150
|
+
process.stdout.write(
|
|
151
|
+
`\n${colors.cyan("ralph-codex run [options]")}\n\n` +
|
|
152
|
+
`${colors.yellow("Options:")}\n` +
|
|
153
|
+
` ${colors.green("--input <path>")} Read tasks from a custom file (alias of --tasks)\n` +
|
|
154
|
+
` ${colors.green("--tasks <path>")} Read tasks from a custom file (default: tasks.md)\n` +
|
|
155
|
+
` ${colors.green("--max-iterations <n>")} Max iterations (default: 15)\n` +
|
|
156
|
+
` ${colors.green("--max-iteration-seconds <n>")} Soft limit per iteration (stop after current loop)\n` +
|
|
157
|
+
` ${colors.green("--max-total-seconds <n>")} Hard limit for the whole run (kills in-flight loop)\n` +
|
|
158
|
+
` ${colors.green("--quiet, -q")} Reduce output\n` +
|
|
159
|
+
` ${colors.green("--completion-promise <text>")} Completion token (default: LOOP_COMPLETE)\n` +
|
|
160
|
+
` ${colors.green("--stop-on-error")} Stop on first error\n` +
|
|
161
|
+
` ${colors.green("--no-log-stream")} Disable log streaming\n` +
|
|
162
|
+
` ${colors.green("--tail-log")} Stream .ralph/loop-log.md\n` +
|
|
163
|
+
` ${colors.green("--tail-scratchpad")} Stream .ralph/summary.md\n` +
|
|
164
|
+
` ${colors.green("--no-tail")} Disable log + scratchpad streaming\n` +
|
|
165
|
+
` ${colors.green("--config <path>")} Path to ralph.config.yml\n` +
|
|
166
|
+
` ${colors.green("--model <name>, -m")} Codex model\n` +
|
|
167
|
+
` ${colors.green("--profile <name>, -p")} Codex CLI profile\n` +
|
|
168
|
+
` ${colors.green("--sandbox <mode>")} read-only | workspace-write | danger-full-access\n` +
|
|
169
|
+
` ${colors.green("--no-sandbox")} Use danger-full-access\n` +
|
|
170
|
+
` ${colors.green("--ask-for-approval <mode>")} untrusted | on-failure | on-request | never\n` +
|
|
171
|
+
` ${colors.green("--full-auto")} workspace-write + on-request\n` +
|
|
172
|
+
` ${colors.green("--reasoning [effort]")} low | medium | high | xhigh (omit to pick)\n` +
|
|
173
|
+
` ${colors.green("-h, --help")} Show help\n\n`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (showHelp) {
|
|
178
|
+
printHelp();
|
|
179
|
+
process.exit(0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function loadConfig(configFilePath) {
|
|
183
|
+
if (!configFilePath) return {};
|
|
184
|
+
if (!fs.existsSync(configFilePath)) return {};
|
|
185
|
+
try {
|
|
186
|
+
const content = fs.readFileSync(configFilePath, "utf8");
|
|
187
|
+
return yaml.load(content) || {};
|
|
188
|
+
} catch (error) {
|
|
189
|
+
console.error(
|
|
190
|
+
`Failed to read config at ${configFilePath}: ${error?.message || error}`,
|
|
191
|
+
);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeReasoningEffort(value) {
|
|
197
|
+
if (value === null || value === undefined) return null;
|
|
198
|
+
const trimmed = String(value).trim();
|
|
199
|
+
if (!trimmed) return null;
|
|
200
|
+
const lowered = trimmed.toLowerCase();
|
|
201
|
+
if (["null", "unset", "none", "default"].includes(lowered)) return null;
|
|
202
|
+
if (lowered === "extra-high" || lowered === "extra_high") return "xhigh";
|
|
203
|
+
if (["low", "medium", "high", "xhigh"].includes(lowered)) return lowered;
|
|
204
|
+
return trimmed;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function promptReasoningEffort(currentValue) {
|
|
208
|
+
const choices = [
|
|
209
|
+
{
|
|
210
|
+
name: "unset",
|
|
211
|
+
message: "unset (null)",
|
|
212
|
+
value: null,
|
|
213
|
+
hint: "Use the Codex default",
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
name: "low",
|
|
217
|
+
message: "low",
|
|
218
|
+
value: "low",
|
|
219
|
+
hint: "Faster, less thorough reasoning.",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "medium",
|
|
223
|
+
message: "medium",
|
|
224
|
+
value: "medium",
|
|
225
|
+
hint: "Default balance of speed + depth.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: "high",
|
|
229
|
+
message: "high",
|
|
230
|
+
value: "high",
|
|
231
|
+
hint: "Deeper reasoning, slower.",
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
name: "xhigh",
|
|
235
|
+
message: "xhigh",
|
|
236
|
+
value: "xhigh",
|
|
237
|
+
hint: "Maximum depth, slowest.",
|
|
238
|
+
},
|
|
239
|
+
];
|
|
240
|
+
const normalized = normalizeReasoningEffort(currentValue) || "medium";
|
|
241
|
+
const initial = Math.max(
|
|
242
|
+
0,
|
|
243
|
+
choices.findIndex((choice) => choice.value === normalized)
|
|
244
|
+
);
|
|
245
|
+
const prompt = new AutoComplete({
|
|
246
|
+
name: "reasoning",
|
|
247
|
+
message: "Select model reasoning effort:",
|
|
248
|
+
choices,
|
|
249
|
+
initial,
|
|
250
|
+
limit: Math.min(choices.length, 7),
|
|
251
|
+
});
|
|
252
|
+
return prompt.run();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function resolveDockerConfig(config) {
|
|
256
|
+
const dockerConfig = config?.docker || {};
|
|
257
|
+
return {
|
|
258
|
+
enabled: Boolean(dockerConfig.enabled),
|
|
259
|
+
dockerfile: dockerConfig.dockerfile || "Dockerfile.ralph",
|
|
260
|
+
image: dockerConfig.image || "ralph-runner",
|
|
261
|
+
baseImage: dockerConfig.base_image || "node:20-bullseye",
|
|
262
|
+
workdir: dockerConfig.workdir || "/workspace",
|
|
263
|
+
codexInstall: dockerConfig.codex_install || "",
|
|
264
|
+
codexHome: dockerConfig.codex_home || ".ralph/codex",
|
|
265
|
+
mountCodexConfig: dockerConfig.mount_codex_config !== false,
|
|
266
|
+
passEnv: Array.isArray(dockerConfig.pass_env) ? dockerConfig.pass_env : [],
|
|
267
|
+
aptPackages: Array.isArray(dockerConfig.apt_packages)
|
|
268
|
+
? dockerConfig.apt_packages
|
|
269
|
+
: [],
|
|
270
|
+
npmGlobals: Array.isArray(dockerConfig.npm_globals)
|
|
271
|
+
? dockerConfig.npm_globals
|
|
272
|
+
: [],
|
|
273
|
+
pipPackages: Array.isArray(dockerConfig.pip_packages)
|
|
274
|
+
? dockerConfig.pip_packages
|
|
275
|
+
: [],
|
|
276
|
+
useForPlan: Boolean(dockerConfig.use_for_plan),
|
|
277
|
+
autoFix: dockerConfig.auto_fix !== false,
|
|
278
|
+
fixAttempts: Number(dockerConfig.fix_attempts || 2),
|
|
279
|
+
fixUseHost: dockerConfig.fix_use_host !== false,
|
|
280
|
+
fixLog: dockerConfig.fix_log || ".ralph/docker-build.log",
|
|
281
|
+
tty: dockerConfig.tty ?? "auto",
|
|
282
|
+
cleanup: dockerConfig.cleanup || "none",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function ensureDockerfile(dockerConfig) {
|
|
287
|
+
if (!dockerConfig.enabled) return;
|
|
288
|
+
const dockerfilePath = path.join(root, dockerConfig.dockerfile);
|
|
289
|
+
const lines = [
|
|
290
|
+
"# Generated by ralph-codex plan",
|
|
291
|
+
`FROM ${dockerConfig.baseImage}`,
|
|
292
|
+
"RUN apt-get update && apt-get install -y git python3 && rm -rf /var/lib/apt/lists/*",
|
|
293
|
+
`WORKDIR ${dockerConfig.workdir}`,
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
if (dockerConfig.codexInstall) {
|
|
297
|
+
lines.push(`RUN ${dockerConfig.codexInstall}`);
|
|
298
|
+
} else {
|
|
299
|
+
lines.push("# TODO: set docker.codex_install in ralph.config.yml");
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fs.writeFileSync(dockerfilePath, `${lines.join("\n")}\n`, "utf8");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
let dockerBuilt = false;
|
|
306
|
+
|
|
307
|
+
function ensureDockerImage(dockerConfig) {
|
|
308
|
+
if (!dockerConfig.enabled || dockerBuilt) return;
|
|
309
|
+
const dockerfilePath = path.join(root, dockerConfig.dockerfile);
|
|
310
|
+
if (!fs.existsSync(dockerfilePath)) {
|
|
311
|
+
console.error(
|
|
312
|
+
`Missing ${dockerConfig.dockerfile}. Run ralph:plan to generate it.`,
|
|
313
|
+
);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
let attempt = 0;
|
|
318
|
+
while (attempt <= dockerConfig.fixAttempts) {
|
|
319
|
+
const result = buildDockerImage(dockerConfig);
|
|
320
|
+
if (result.notRunning) {
|
|
321
|
+
const msg =
|
|
322
|
+
result.output ||
|
|
323
|
+
"Docker is not running. Start Docker Desktop or Colima and retry.";
|
|
324
|
+
process.stderr.write(`\n${colors.red(msg)}\n`);
|
|
325
|
+
process.exit(result.status ?? 1);
|
|
326
|
+
}
|
|
327
|
+
if (detectDockerStorageIssue(result.output)) {
|
|
328
|
+
printDockerStorageHint(result.output);
|
|
329
|
+
process.exit(result.status ?? 1);
|
|
330
|
+
}
|
|
331
|
+
if (result.status === 0) {
|
|
332
|
+
dockerBuilt = true;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const logPath = path.join(root, dockerConfig.fixLog);
|
|
337
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
338
|
+
fs.writeFileSync(logPath, result.output, "utf8");
|
|
339
|
+
|
|
340
|
+
if (!dockerConfig.autoFix || attempt >= dockerConfig.fixAttempts) {
|
|
341
|
+
console.error(
|
|
342
|
+
`Docker build failed. See ${dockerConfig.fixLog} for details.`,
|
|
343
|
+
);
|
|
344
|
+
process.exit(result.status ?? 1);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const before = fs.readFileSync(dockerfilePath, "utf8");
|
|
348
|
+
const prompt = `Docker build failed. Fix the Dockerfile only.
|
|
349
|
+
|
|
350
|
+
Dockerfile:
|
|
351
|
+
${before}
|
|
352
|
+
|
|
353
|
+
Build error:
|
|
354
|
+
${result.output}
|
|
355
|
+
|
|
356
|
+
Constraints:
|
|
357
|
+
- Only edit ${dockerConfig.dockerfile}
|
|
358
|
+
- Do not change other files
|
|
359
|
+
- Do not ask questions
|
|
360
|
+
- Output exactly: LOOP_COMPLETE
|
|
361
|
+
`;
|
|
362
|
+
process.stdout.write(
|
|
363
|
+
`\nAttempting Dockerfile auto-fix (${attempt + 1}/${
|
|
364
|
+
dockerConfig.fixAttempts
|
|
365
|
+
})...\n`,
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (!dockerConfig.fixUseHost) {
|
|
369
|
+
console.error("docker.fix_use_host must be true for auto-fix.");
|
|
370
|
+
process.exit(1);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
runCodexHost(prompt);
|
|
374
|
+
const after = fs.readFileSync(dockerfilePath, "utf8");
|
|
375
|
+
if (after === before) {
|
|
376
|
+
console.error("Auto-fix did not change the Dockerfile.");
|
|
377
|
+
process.exit(1);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
attempt += 1;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function buildDockerRunArgs(dockerConfig) {
|
|
385
|
+
const args = ["run", "--rm", "-i"];
|
|
386
|
+
const wantsTty =
|
|
387
|
+
dockerConfig.tty === true ||
|
|
388
|
+
dockerConfig.tty === "true" ||
|
|
389
|
+
(dockerConfig.tty === "auto" && process.stdin.isTTY);
|
|
390
|
+
if (wantsTty) args.push("-t");
|
|
391
|
+
args.push(
|
|
392
|
+
"-v",
|
|
393
|
+
`${root}:${dockerConfig.workdir}`,
|
|
394
|
+
"-w",
|
|
395
|
+
dockerConfig.workdir,
|
|
396
|
+
);
|
|
397
|
+
const codexHome = path.isAbsolute(dockerConfig.codexHome)
|
|
398
|
+
? dockerConfig.codexHome
|
|
399
|
+
: path.join(root, dockerConfig.codexHome);
|
|
400
|
+
fs.mkdirSync(codexHome, { recursive: true });
|
|
401
|
+
if (dockerConfig.mountCodexConfig) {
|
|
402
|
+
const hostCodex = path.join(os.homedir(), ".codex");
|
|
403
|
+
if (fs.existsSync(hostCodex)) {
|
|
404
|
+
const existing = fs.readdirSync(codexHome);
|
|
405
|
+
if (existing.length === 0) {
|
|
406
|
+
fs.cpSync(hostCodex, codexHome, { recursive: true });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
args.push("-v", `${codexHome}:/root/.codex`, "-e", "HOME=/root");
|
|
411
|
+
|
|
412
|
+
for (const envName of dockerConfig.passEnv) {
|
|
413
|
+
const value = process.env[envName];
|
|
414
|
+
if (value) args.push("-e", `${envName}=${value}`);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return args;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function cleanupDockerImage(dockerConfig) {
|
|
421
|
+
if (!dockerConfig.enabled) return;
|
|
422
|
+
if (dockerConfig.cleanup !== "image" && dockerConfig.cleanup !== "all")
|
|
423
|
+
return;
|
|
424
|
+
|
|
425
|
+
const result = spawnSync("docker", ["rmi", "-f", dockerConfig.image], {
|
|
426
|
+
encoding: "utf8",
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
if (result.status !== 0) {
|
|
430
|
+
const message = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
|
|
431
|
+
if (message) {
|
|
432
|
+
process.stderr.write(`\nDocker cleanup warning: ${message}\n`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (dockerConfig.cleanup === "all") {
|
|
437
|
+
spawnSync("docker", ["system", "prune", "-f"], { stdio: "ignore" });
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function runCodexHost(prompt) {
|
|
442
|
+
const args = ["exec"];
|
|
443
|
+
if (model) args.push("--model", model);
|
|
444
|
+
if (profile) args.push("--profile", profile);
|
|
445
|
+
if (fullAuto) args.push("--full-auto");
|
|
446
|
+
if (askForApproval) {
|
|
447
|
+
args.push("--config", `ask_for_approval=${askForApproval}`);
|
|
448
|
+
}
|
|
449
|
+
if (modelReasoningEffort) {
|
|
450
|
+
args.push("--config", `model_reasoning_effort=${modelReasoningEffort}`);
|
|
451
|
+
}
|
|
452
|
+
if (resolvedSandbox) args.push("--sandbox", resolvedSandbox);
|
|
453
|
+
args.push("-");
|
|
454
|
+
|
|
455
|
+
const result = spawnSync("codex", args, {
|
|
456
|
+
input: prompt,
|
|
457
|
+
encoding: "utf8",
|
|
458
|
+
cwd: root,
|
|
459
|
+
env: process.env,
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const combined = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
|
|
463
|
+
if (combined) process.stdout.write(`${combined}\n`);
|
|
464
|
+
return { status: result.status ?? 0, output: combined };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function buildDockerImage(dockerConfig) {
|
|
468
|
+
const dockerfilePath = path.join(root, dockerConfig.dockerfile);
|
|
469
|
+
const probe = spawnSync("docker", ["info"], { encoding: "utf8" });
|
|
470
|
+
if (probe.status !== 0) {
|
|
471
|
+
return {
|
|
472
|
+
status: probe.status ?? 1,
|
|
473
|
+
output: `${probe.stdout || ""}\n${probe.stderr || ""}`.trim(),
|
|
474
|
+
notRunning: true,
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
const result = spawnSync(
|
|
478
|
+
"docker",
|
|
479
|
+
["build", "-f", dockerfilePath, "-t", dockerConfig.image, "."],
|
|
480
|
+
{ cwd: root, encoding: "utf8" },
|
|
481
|
+
);
|
|
482
|
+
const output = `${result.stdout || ""}\n${result.stderr || ""}`.trim();
|
|
483
|
+
if (output) process.stdout.write(`${output}\n`);
|
|
484
|
+
return { status: result.status ?? 1, output };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const resolvedConfigPath = configPath || path.join(root, "ralph.config.yml");
|
|
488
|
+
const config = loadConfig(resolvedConfigPath);
|
|
489
|
+
const codexConfig = config?.codex || {};
|
|
490
|
+
const runConfig = config?.run || {};
|
|
491
|
+
const dockerConfig = resolveDockerConfig(config);
|
|
492
|
+
activeDockerConfig = dockerConfig;
|
|
493
|
+
const requiredCommands = Array.isArray(runConfig.required_commands)
|
|
494
|
+
? runConfig.required_commands.filter(Boolean)
|
|
495
|
+
: [];
|
|
496
|
+
const requiredCommandsSection =
|
|
497
|
+
requiredCommands.length > 0
|
|
498
|
+
? requiredCommands.map((cmd) => `- Run \`${cmd}\`.`).join("\n")
|
|
499
|
+
: "- None.";
|
|
500
|
+
|
|
501
|
+
if (!model && codexConfig.model) model = codexConfig.model;
|
|
502
|
+
if (!profile && codexConfig.profile) profile = codexConfig.profile;
|
|
503
|
+
if (!sandbox && codexConfig.sandbox) sandbox = codexConfig.sandbox;
|
|
504
|
+
if (!askForApproval && codexConfig.ask_for_approval) {
|
|
505
|
+
askForApproval = codexConfig.ask_for_approval;
|
|
506
|
+
}
|
|
507
|
+
if (!fullAuto && codexConfig.full_auto) fullAuto = true;
|
|
508
|
+
if (!modelReasoningEffort && codexConfig.model_reasoning_effort) {
|
|
509
|
+
modelReasoningEffort = codexConfig.model_reasoning_effort;
|
|
510
|
+
}
|
|
511
|
+
if (tasksPath === "tasks.md" && runConfig.tasks_path) {
|
|
512
|
+
tasksPath = runConfig.tasks_path;
|
|
513
|
+
}
|
|
514
|
+
if (maxIterations === 15 && runConfig.max_iterations) {
|
|
515
|
+
maxIterations = runConfig.max_iterations;
|
|
516
|
+
}
|
|
517
|
+
if (maxIterationSeconds === null && runConfig.max_iteration_seconds) {
|
|
518
|
+
const value = Number(runConfig.max_iteration_seconds);
|
|
519
|
+
if (Number.isFinite(value) && value > 0) {
|
|
520
|
+
maxIterationSeconds = value;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (maxTotalSeconds === null && runConfig.max_total_seconds) {
|
|
524
|
+
const value = Number(runConfig.max_total_seconds);
|
|
525
|
+
if (Number.isFinite(value) && value > 0) {
|
|
526
|
+
maxTotalSeconds = value;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (completionPromise === "LOOP_COMPLETE" && runConfig.completion_promise) {
|
|
530
|
+
completionPromise = runConfig.completion_promise;
|
|
531
|
+
}
|
|
532
|
+
if (typeof runConfig.tail_log === "boolean") {
|
|
533
|
+
streamLog = runConfig.tail_log;
|
|
534
|
+
}
|
|
535
|
+
if (typeof runConfig.tail_scratchpad === "boolean") {
|
|
536
|
+
streamScratchpad = runConfig.tail_scratchpad;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const tasksFile = path.join(root, tasksPath);
|
|
540
|
+
|
|
541
|
+
if (!fs.existsSync(tasksFile)) {
|
|
542
|
+
console.error(`Missing ${tasksPath}. Run ralph:plan or create it first.`);
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (activeDockerConfig?.enabled) {
|
|
547
|
+
const probe = spawnSync("docker", ["info"], { encoding: "utf8" });
|
|
548
|
+
if (probe.status !== 0) {
|
|
549
|
+
const msg =
|
|
550
|
+
`${probe.stdout || ""}\n${probe.stderr || ""}`.trim() ||
|
|
551
|
+
"Docker is not running. Start Docker Desktop or Colima and retry.";
|
|
552
|
+
process.stderr.write(`\n${colors.red(msg)}\n`);
|
|
553
|
+
process.exit(probe.status ?? 1);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
fs.mkdirSync(agentDir, { recursive: true });
|
|
558
|
+
|
|
559
|
+
const promptPath = path.join(agentDir, "ralph-run-prompt.md");
|
|
560
|
+
const scratchpadPath = path.join(agentDir, "summary.md");
|
|
561
|
+
const logPath = path.join(agentDir, "loop-log.md");
|
|
562
|
+
|
|
563
|
+
if (!fs.existsSync(logPath)) {
|
|
564
|
+
fs.writeFileSync(logPath, "", "utf8");
|
|
565
|
+
}
|
|
566
|
+
if (!fs.existsSync(scratchpadPath)) {
|
|
567
|
+
fs.writeFileSync(scratchpadPath, "", "utf8");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
let promptBase = "";
|
|
571
|
+
|
|
572
|
+
function buildPromptBase() {
|
|
573
|
+
const dockerNotes = activeDockerConfig?.enabled
|
|
574
|
+
? `\nDocker environment:\n- You are running inside Docker with the repo mounted at ${activeDockerConfig.workdir}.\n- HOME is /root and Codex data lives in /root/.codex (mounted from ${activeDockerConfig.codexHome}).\n- Python and pip use a venv at /opt/venv (PATH already includes /opt/venv/bin).\n- Skip host version managers; the container already pins the runtime from the base image.\n- There are no host port mappings by default; if you start a dev server, access it from inside the container.\n- If a required tool is missing, log the blocker and do not edit the Dockerfile in run mode.\n`
|
|
575
|
+
: "";
|
|
576
|
+
|
|
577
|
+
return `# Ralph Run
|
|
578
|
+
|
|
579
|
+
Repo: ${repoName}
|
|
580
|
+
|
|
581
|
+
Required at the start of each iteration:
|
|
582
|
+
${requiredCommandsSection}
|
|
583
|
+
|
|
584
|
+
Inputs:
|
|
585
|
+
- ${tasksPath} (task list, check items off as completed)
|
|
586
|
+
- ${path.relative(root, scratchpadPath)} (summary notes for context)
|
|
587
|
+
- ${path.relative(root, logPath)} (iteration log; append every loop)
|
|
588
|
+
${dockerNotes}
|
|
589
|
+
|
|
590
|
+
Process:
|
|
591
|
+
1) Re-read ${tasksPath} and ${path.relative(root, logPath)} each iteration.
|
|
592
|
+
2) Do not add or remove tasks. The list is immutable.
|
|
593
|
+
3) Only update existing tasks by marking:
|
|
594
|
+
- \`[x]\` for completed
|
|
595
|
+
- \`[~]\` for failed/blocked
|
|
596
|
+
4) If a task is not started, leave it blank \`[ ]\`.
|
|
597
|
+
5) If needed, start the app or run tests. Prefer local-only bind:
|
|
598
|
+
set \`HOST=127.0.0.1\` and \`PORT=3000\` (or project defaults) when launching a dev server.
|
|
599
|
+
6) Append a new section to ${path.relative(root, logPath)} with:
|
|
600
|
+
- Iteration number
|
|
601
|
+
- Changes made
|
|
602
|
+
- Commands run + results
|
|
603
|
+
- Blockers + next step
|
|
604
|
+
7) Keep edits minimal and aligned with the tasks.
|
|
605
|
+
8) Do not ask the user clarifying questions. If unsure, make the best
|
|
606
|
+
reasonable assumption, document it in the log, and proceed.
|
|
607
|
+
|
|
608
|
+
Completion requirements:
|
|
609
|
+
- All tasks are checked in ${tasksPath}
|
|
610
|
+
- All items listed under "Success criteria" in ${tasksPath} are satisfied
|
|
611
|
+
|
|
612
|
+
When complete, output exactly: ${completionPromise}
|
|
613
|
+
`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const resolvedSandbox = noSandbox ? "danger-full-access" : sandbox;
|
|
617
|
+
let completed = false;
|
|
618
|
+
let lastStatus = 0;
|
|
619
|
+
let lastLogSize = 0;
|
|
620
|
+
let lastOutput = "";
|
|
621
|
+
let lastScratchpadSize = 0;
|
|
622
|
+
let fatalDockerError = null;
|
|
623
|
+
|
|
624
|
+
function getTaskProgress(tasksFilePath) {
|
|
625
|
+
if (!fs.existsSync(tasksFilePath)) {
|
|
626
|
+
return { completed: 0, blocked: 0, total: 0, percent: 0 };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const content = fs.readFileSync(tasksFilePath, "utf8");
|
|
630
|
+
const lines = content.split(/\r?\n/);
|
|
631
|
+
let total = 0;
|
|
632
|
+
let completedCount = 0;
|
|
633
|
+
let blockedCount = 0;
|
|
634
|
+
|
|
635
|
+
for (const line of lines) {
|
|
636
|
+
const match = line.match(/^\s*[-*]\s+\[([ x~])\]/i);
|
|
637
|
+
if (!match) continue;
|
|
638
|
+
total += 1;
|
|
639
|
+
const status = match[1].toLowerCase();
|
|
640
|
+
if (status === "x") {
|
|
641
|
+
completedCount += 1;
|
|
642
|
+
}
|
|
643
|
+
if (status === "~") {
|
|
644
|
+
blockedCount += 1;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const percent = total === 0 ? 0 : Math.round((completedCount / total) * 100);
|
|
649
|
+
return { completed: completedCount, blocked: blockedCount, total, percent };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function validateTasksFile(tasksFilePath) {
|
|
653
|
+
const warnings = [];
|
|
654
|
+
if (!fs.existsSync(tasksFilePath)) {
|
|
655
|
+
warnings.push(`Missing ${tasksFilePath}.`);
|
|
656
|
+
return warnings;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const content = fs.readFileSync(tasksFilePath, "utf8");
|
|
660
|
+
const lines = content.split(/\r?\n/);
|
|
661
|
+
let taskCount = 0;
|
|
662
|
+
let invalidTasks = 0;
|
|
663
|
+
let successHeaderIndex = -1;
|
|
664
|
+
|
|
665
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
666
|
+
const line = lines[i];
|
|
667
|
+
if (/^(#+\s*)?success criteria\b/i.test(line.trim())) {
|
|
668
|
+
successHeaderIndex = i;
|
|
669
|
+
}
|
|
670
|
+
const match = line.match(/^\s*[-*]\s+\[([^\]])\]\s+.+/);
|
|
671
|
+
if (!match) continue;
|
|
672
|
+
taskCount += 1;
|
|
673
|
+
const status = match[1].trim().toLowerCase();
|
|
674
|
+
if (!["", "x", "~"].includes(status)) {
|
|
675
|
+
invalidTasks += 1;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (taskCount === 0) {
|
|
680
|
+
warnings.push("No tasks found. Expected list items like '- [ ] Task'.");
|
|
681
|
+
}
|
|
682
|
+
if (invalidTasks > 0) {
|
|
683
|
+
warnings.push(
|
|
684
|
+
`Found ${invalidTasks} task(s) with invalid status. Use [ ], [x], or [~].`,
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
if (successHeaderIndex === -1) {
|
|
689
|
+
warnings.push('Missing "Success criteria" section.');
|
|
690
|
+
} else {
|
|
691
|
+
let successItems = 0;
|
|
692
|
+
for (let i = successHeaderIndex + 1; i < lines.length; i += 1) {
|
|
693
|
+
const line = lines[i].trim();
|
|
694
|
+
if (!line) continue;
|
|
695
|
+
if (/^#+\s+/.test(line)) break;
|
|
696
|
+
if (line.startsWith("- ")) successItems += 1;
|
|
697
|
+
if (line.toLowerCase().startsWith("success criteria")) continue;
|
|
698
|
+
}
|
|
699
|
+
if (successItems === 0) {
|
|
700
|
+
warnings.push("Success criteria section has no list items.");
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return warnings;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function getLastBlocker(logFilePath) {
|
|
708
|
+
if (!fs.existsSync(logFilePath)) return "";
|
|
709
|
+
const content = fs.readFileSync(logFilePath, "utf8");
|
|
710
|
+
const lines = content.split(/\r?\n/);
|
|
711
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
712
|
+
const line = lines[i];
|
|
713
|
+
const match = line.match(/^- Blockers \+ next step:\s*(.*)$/);
|
|
714
|
+
if (match) {
|
|
715
|
+
const text = match[1].trim();
|
|
716
|
+
if (text) return text;
|
|
717
|
+
for (let j = i + 1; j < lines.length; j += 1) {
|
|
718
|
+
const next = lines[j].trim();
|
|
719
|
+
if (next) return next;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
return "";
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function truncate(text, maxLen) {
|
|
727
|
+
if (text.length <= maxLen) return text;
|
|
728
|
+
return `${text.slice(0, Math.max(0, maxLen - 3)).trim()}...`;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function detectDockerStorageIssue(output) {
|
|
732
|
+
const text = output.toLowerCase();
|
|
733
|
+
if (
|
|
734
|
+
text.includes("read-only file system") ||
|
|
735
|
+
text.includes("input/output error") ||
|
|
736
|
+
text.includes("error creating temporary lease") ||
|
|
737
|
+
text.includes("overlay2") ||
|
|
738
|
+
(text.includes("containerd") && text.includes("metadata")) ||
|
|
739
|
+
text.includes("failed to initialize session")
|
|
740
|
+
) {
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
return false;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function printDockerStorageHint(output) {
|
|
747
|
+
process.stderr.write(
|
|
748
|
+
`\n${colors.red("Docker storage error detected.")}\n` +
|
|
749
|
+
`${colors.yellow("Likely cause:")} Docker Desktop filesystem is read-only or corrupted.\n` +
|
|
750
|
+
`${colors.yellow("Recommended fixes:")}\n` +
|
|
751
|
+
`- Restart Docker Desktop\n` +
|
|
752
|
+
`- Check disk space\n` +
|
|
753
|
+
`- Docker Desktop -> Troubleshoot -> Clean/Purge data\n` +
|
|
754
|
+
`- Reinstall Docker or switch to Colima\n` +
|
|
755
|
+
`- Temporary bypass: set docker.enabled=false in ralph.config.yml\n`,
|
|
756
|
+
);
|
|
757
|
+
if (output) {
|
|
758
|
+
process.stderr.write(
|
|
759
|
+
`${colors.yellow("Raw error (truncated):")} ${truncate(output, 240)}\n`,
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function extractLatestIteration(logContent) {
|
|
765
|
+
const parts = logContent.split(/^##\s+Iteration\s+/m).filter(Boolean);
|
|
766
|
+
if (parts.length === 0) return null;
|
|
767
|
+
const last = parts[parts.length - 1];
|
|
768
|
+
const lines = last.split(/\r?\n/);
|
|
769
|
+
const title = lines[0] ? lines[0].trim() : "";
|
|
770
|
+
|
|
771
|
+
const section = (label) => {
|
|
772
|
+
const index = lines.findIndex((line) => line.startsWith(label));
|
|
773
|
+
if (index === -1) return [];
|
|
774
|
+
const items = [];
|
|
775
|
+
for (let i = index + 1; i < lines.length; i += 1) {
|
|
776
|
+
const line = lines[i];
|
|
777
|
+
if (line.startsWith("- ") && !line.startsWith(label)) {
|
|
778
|
+
items.push(line.slice(2).trim());
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
if (line.startsWith("## ")) break;
|
|
782
|
+
if (line.startsWith("- ")) continue;
|
|
783
|
+
if (line.startsWith(" - ")) {
|
|
784
|
+
items.push(line.slice(4).trim());
|
|
785
|
+
} else if (line.trim() && !line.startsWith("-")) {
|
|
786
|
+
items.push(line.trim());
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return items;
|
|
790
|
+
};
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
title,
|
|
794
|
+
changes: section("- Changes made:"),
|
|
795
|
+
commands: section("- Commands run + results:"),
|
|
796
|
+
blockers: section("- Blockers + next step:"),
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function writeSummary(summaryPath, data) {
|
|
801
|
+
const lines = [];
|
|
802
|
+
lines.push(`# Summary`);
|
|
803
|
+
lines.push(``);
|
|
804
|
+
lines.push(`- Status: ${data.status}`);
|
|
805
|
+
lines.push(`- Iterations: ${data.iterations}`);
|
|
806
|
+
lines.push(
|
|
807
|
+
`- Progress: ${data.progress.completed}/${data.progress.total} (${data.progress.percent}%)`,
|
|
808
|
+
);
|
|
809
|
+
lines.push(``);
|
|
810
|
+
if (data.latest?.title) {
|
|
811
|
+
lines.push(`## Latest iteration`);
|
|
812
|
+
lines.push(`- ${data.latest.title}`);
|
|
813
|
+
lines.push(``);
|
|
814
|
+
}
|
|
815
|
+
if (data.latest?.changes?.length) {
|
|
816
|
+
lines.push(`## Changes`);
|
|
817
|
+
data.latest.changes.forEach((item) => lines.push(`- ${item}`));
|
|
818
|
+
lines.push(``);
|
|
819
|
+
}
|
|
820
|
+
if (data.latest?.commands?.length) {
|
|
821
|
+
lines.push(`## Commands`);
|
|
822
|
+
data.latest.commands.forEach((item) => lines.push(`- ${item}`));
|
|
823
|
+
lines.push(``);
|
|
824
|
+
}
|
|
825
|
+
if (data.latest?.blockers?.length) {
|
|
826
|
+
lines.push(`## Blockers`);
|
|
827
|
+
data.latest.blockers.forEach((item) => lines.push(`- ${item}`));
|
|
828
|
+
lines.push(``);
|
|
829
|
+
}
|
|
830
|
+
if (data.notes?.length) {
|
|
831
|
+
lines.push(`## Notes`);
|
|
832
|
+
data.notes.forEach((note) => lines.push(`- ${note}`));
|
|
833
|
+
lines.push(``);
|
|
834
|
+
}
|
|
835
|
+
fs.writeFileSync(summaryPath, lines.join("\n"), "utf8");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
function readNewLogChunk() {
|
|
839
|
+
if (!fs.existsSync(logPath)) return;
|
|
840
|
+
const stats = fs.statSync(logPath);
|
|
841
|
+
if (stats.size <= lastLogSize) return;
|
|
842
|
+
const log = fs.readFileSync(logPath, "utf8").slice(lastLogSize);
|
|
843
|
+
lastLogSize = stats.size;
|
|
844
|
+
if (log.trim()) {
|
|
845
|
+
process.stdout.write(`\n--- Ralph Loop Log update ---\n\n${log}\n`);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function readNewScratchpadChunk() {
|
|
850
|
+
if (!fs.existsSync(scratchpadPath)) return;
|
|
851
|
+
const stats = fs.statSync(scratchpadPath);
|
|
852
|
+
if (stats.size <= lastScratchpadSize) return;
|
|
853
|
+
const log = fs.readFileSync(scratchpadPath, "utf8").slice(lastScratchpadSize);
|
|
854
|
+
lastScratchpadSize = stats.size;
|
|
855
|
+
if (log.trim()) {
|
|
856
|
+
process.stdout.write(`\n--- Ralph Scratchpad update ---\n\n${log}\n`);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function runCodex(prompt, options = {}) {
|
|
861
|
+
return new Promise((resolve) => {
|
|
862
|
+
const args = ["exec"];
|
|
863
|
+
if (model) args.push("--model", model);
|
|
864
|
+
if (profile) args.push("--profile", profile);
|
|
865
|
+
if (fullAuto) args.push("--full-auto");
|
|
866
|
+
if (askForApproval) {
|
|
867
|
+
args.push("--config", `ask_for_approval=${askForApproval}`);
|
|
868
|
+
}
|
|
869
|
+
if (modelReasoningEffort) {
|
|
870
|
+
args.push("--config", `model_reasoning_effort=${modelReasoningEffort}`);
|
|
871
|
+
}
|
|
872
|
+
if (resolvedSandbox) args.push("--sandbox", resolvedSandbox);
|
|
873
|
+
args.push("-");
|
|
874
|
+
|
|
875
|
+
let command = "codex";
|
|
876
|
+
let commandArgs = args;
|
|
877
|
+
if (activeDockerConfig?.enabled) {
|
|
878
|
+
if (!activeDockerConfig.codexInstall) {
|
|
879
|
+
console.error(
|
|
880
|
+
"docker.codex_install is required when docker.enabled is true.",
|
|
881
|
+
);
|
|
882
|
+
process.exit(1);
|
|
883
|
+
}
|
|
884
|
+
ensureDockerImage(activeDockerConfig);
|
|
885
|
+
command = "docker";
|
|
886
|
+
commandArgs = [
|
|
887
|
+
...buildDockerRunArgs(activeDockerConfig),
|
|
888
|
+
activeDockerConfig.image,
|
|
889
|
+
"codex",
|
|
890
|
+
...args,
|
|
891
|
+
];
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const child = spawn(command, commandArgs, {
|
|
895
|
+
cwd: root,
|
|
896
|
+
env: process.env,
|
|
897
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
const styler = createLogStyler();
|
|
901
|
+
let output = "";
|
|
902
|
+
let lineBuffer = "";
|
|
903
|
+
let hardTimedOut = false;
|
|
904
|
+
const hardTimeoutMs = Number.isFinite(options.hardTimeoutMs)
|
|
905
|
+
? options.hardTimeoutMs
|
|
906
|
+
: null;
|
|
907
|
+
let hardTimeoutId = null;
|
|
908
|
+
let killTimer = null;
|
|
909
|
+
|
|
910
|
+
const killChild = () => {
|
|
911
|
+
if (child.killed) return;
|
|
912
|
+
try {
|
|
913
|
+
child.kill("SIGTERM");
|
|
914
|
+
} catch (_) {}
|
|
915
|
+
killTimer = setTimeout(() => {
|
|
916
|
+
if (!child.killed) {
|
|
917
|
+
try {
|
|
918
|
+
child.kill("SIGKILL");
|
|
919
|
+
} catch (_) {}
|
|
920
|
+
}
|
|
921
|
+
}, 4000);
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
if (hardTimeoutMs && hardTimeoutMs > 0) {
|
|
925
|
+
hardTimeoutId = setTimeout(() => {
|
|
926
|
+
hardTimedOut = true;
|
|
927
|
+
killChild();
|
|
928
|
+
}, hardTimeoutMs);
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
const writeWithColor = (text, isStderr = false) => {
|
|
932
|
+
if (isStderr) {
|
|
933
|
+
process.stderr.write(text);
|
|
934
|
+
} else {
|
|
935
|
+
process.stdout.write(text);
|
|
936
|
+
}
|
|
937
|
+
};
|
|
938
|
+
|
|
939
|
+
const flushLines = (chunk, isStderr = false) => {
|
|
940
|
+
lineBuffer += chunk;
|
|
941
|
+
const lines = lineBuffer.split(/\r?\n/);
|
|
942
|
+
lineBuffer = lines.pop() || "";
|
|
943
|
+
|
|
944
|
+
for (const line of lines) {
|
|
945
|
+
writeWithColor(`${styler.formatLine(line)}\n`, isStderr);
|
|
946
|
+
}
|
|
947
|
+
};
|
|
948
|
+
child.stdout.on("data", (chunk) => {
|
|
949
|
+
const text = chunk.toString();
|
|
950
|
+
output += text;
|
|
951
|
+
flushLines(text, false);
|
|
952
|
+
});
|
|
953
|
+
child.stderr.on("data", (chunk) => {
|
|
954
|
+
const text = chunk.toString();
|
|
955
|
+
output += text;
|
|
956
|
+
flushLines(text, true);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
const cleanupTimers = () => {
|
|
960
|
+
if (hardTimeoutId) clearTimeout(hardTimeoutId);
|
|
961
|
+
if (killTimer) clearTimeout(killTimer);
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
if (streamLog || streamScratchpad) {
|
|
965
|
+
const interval = setInterval(() => {
|
|
966
|
+
if (streamLog) readNewLogChunk();
|
|
967
|
+
if (streamScratchpad) readNewScratchpadChunk();
|
|
968
|
+
}, 500);
|
|
969
|
+
child.on("close", (code) => {
|
|
970
|
+
clearInterval(interval);
|
|
971
|
+
cleanupTimers();
|
|
972
|
+
if (streamLog) readNewLogChunk();
|
|
973
|
+
if (streamScratchpad) readNewScratchpadChunk();
|
|
974
|
+
if (lineBuffer) {
|
|
975
|
+
flushLines("\n");
|
|
976
|
+
}
|
|
977
|
+
resolve({ code: code ?? 0, output, timedOut: hardTimedOut });
|
|
978
|
+
});
|
|
979
|
+
} else {
|
|
980
|
+
child.on("close", (code) => {
|
|
981
|
+
cleanupTimers();
|
|
982
|
+
if (lineBuffer) {
|
|
983
|
+
flushLines("\n");
|
|
984
|
+
}
|
|
985
|
+
resolve({ code: code ?? 0, output, timedOut: hardTimedOut });
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
child.stdin.write(prompt);
|
|
990
|
+
child.stdin.end();
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function main() {
|
|
995
|
+
const hasCompletion = (output) =>
|
|
996
|
+
output.split(/\r?\n/).some((line) => line.trim() === completionPromise);
|
|
997
|
+
|
|
998
|
+
if (typeof reasoningChoice !== "undefined") {
|
|
999
|
+
if (reasoningChoice === "__prompt__") {
|
|
1000
|
+
modelReasoningEffort = await promptReasoningEffort(modelReasoningEffort);
|
|
1001
|
+
} else {
|
|
1002
|
+
modelReasoningEffort = normalizeReasoningEffort(reasoningChoice);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
promptBase = buildPromptBase();
|
|
1007
|
+
fs.writeFileSync(promptPath, promptBase, "utf8");
|
|
1008
|
+
|
|
1009
|
+
const warnings = validateTasksFile(tasksFile);
|
|
1010
|
+
if (warnings.length > 0) {
|
|
1011
|
+
process.stdout.write(`\n${colors.yellow("Task file warnings:")}\n`);
|
|
1012
|
+
warnings.forEach((warning) =>
|
|
1013
|
+
process.stdout.write(`${colors.yellow(`- ${warning}`)}\n`),
|
|
1014
|
+
);
|
|
1015
|
+
process.stdout.write("\n");
|
|
1016
|
+
|
|
1017
|
+
const confirm = new Confirm({
|
|
1018
|
+
name: "continue",
|
|
1019
|
+
message: "Continue despite warnings?",
|
|
1020
|
+
initial: false,
|
|
1021
|
+
});
|
|
1022
|
+
const shouldContinue = await confirm.run();
|
|
1023
|
+
if (!shouldContinue) {
|
|
1024
|
+
process.stdout.write("Aborted by user.\n");
|
|
1025
|
+
process.exit(1);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
const initialProgress = getTaskProgress(tasksFile);
|
|
1030
|
+
if (
|
|
1031
|
+
initialProgress.total > 0 &&
|
|
1032
|
+
initialProgress.completed === initialProgress.total
|
|
1033
|
+
) {
|
|
1034
|
+
process.stdout.write(
|
|
1035
|
+
`\n${colors.green(
|
|
1036
|
+
`All tasks are already completed (${initialProgress.completed}/${initialProgress.total}).`,
|
|
1037
|
+
)}\n`,
|
|
1038
|
+
);
|
|
1039
|
+
process.exit(0);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const progressBar = createProgressBar();
|
|
1043
|
+
const barWidth = 20;
|
|
1044
|
+
const renderProgressBar = (progress, iteration) => {
|
|
1045
|
+
if (!progressBar || progress.total === 0) return false;
|
|
1046
|
+
progressBar.start(progress.total, progress.completed, {
|
|
1047
|
+
blocked: progress.blocked,
|
|
1048
|
+
iteration,
|
|
1049
|
+
iterations: maxIterations,
|
|
1050
|
+
});
|
|
1051
|
+
progressBar.stop();
|
|
1052
|
+
return true;
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
const notes = [];
|
|
1056
|
+
const startTimeMs = Date.now();
|
|
1057
|
+
const iterationBudgetMs = Number.isFinite(maxIterationSeconds)
|
|
1058
|
+
? Math.round(maxIterationSeconds * 1000)
|
|
1059
|
+
: null;
|
|
1060
|
+
const totalBudgetMs = Number.isFinite(maxTotalSeconds)
|
|
1061
|
+
? Math.round(maxTotalSeconds * 1000)
|
|
1062
|
+
: null;
|
|
1063
|
+
let iterationTimeLimitHit = false;
|
|
1064
|
+
let totalTimeLimitHit = false;
|
|
1065
|
+
let iterationsUsed = 0;
|
|
1066
|
+
for (let iteration = 1; iteration <= maxIterations; iteration += 1) {
|
|
1067
|
+
if (totalBudgetMs) {
|
|
1068
|
+
const elapsed = Date.now() - startTimeMs;
|
|
1069
|
+
const remaining = totalBudgetMs - elapsed;
|
|
1070
|
+
if (remaining <= 0) {
|
|
1071
|
+
totalTimeLimitHit = true;
|
|
1072
|
+
notes.push(
|
|
1073
|
+
`Total time limit exceeded before iteration ${iteration} (limit: ${maxTotalSeconds}s).`,
|
|
1074
|
+
);
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
iterationsUsed = iteration;
|
|
1079
|
+
const prompt = `${promptBase}\nIteration: ${iteration} of ${maxIterations}\n`;
|
|
1080
|
+
const progress = getTaskProgress(tasksFile);
|
|
1081
|
+
const filled = Math.round((progress.percent / 100) * barWidth);
|
|
1082
|
+
const empty = barWidth - filled;
|
|
1083
|
+
const bar = `[${"#".repeat(filled)}${"-".repeat(empty)}]`;
|
|
1084
|
+
const lastBlocker = getLastBlocker(logPath);
|
|
1085
|
+
process.stdout.write(
|
|
1086
|
+
`\n${colors.blue(`=== Iteration ${iteration}/${maxIterations} ===`)}\n`,
|
|
1087
|
+
);
|
|
1088
|
+
if (!renderProgressBar(progress, iteration)) {
|
|
1089
|
+
process.stdout.write(
|
|
1090
|
+
`${colors.cyan(bar)} ` +
|
|
1091
|
+
`${colors.green(`✓ ${progress.completed}`)} ` +
|
|
1092
|
+
`${colors.yellow(`~ ${progress.blocked}`)} / ` +
|
|
1093
|
+
`${progress.total} (${progress.percent}%)\n`,
|
|
1094
|
+
);
|
|
1095
|
+
}
|
|
1096
|
+
if (lastBlocker) {
|
|
1097
|
+
process.stdout.write(
|
|
1098
|
+
`${colors.yellow(`Last blocker: ${truncate(lastBlocker, 140)}`)}\n\n`,
|
|
1099
|
+
);
|
|
1100
|
+
} else {
|
|
1101
|
+
process.stdout.write("\n");
|
|
1102
|
+
}
|
|
1103
|
+
let iterationTimeoutId = null;
|
|
1104
|
+
let iterationTimedOut = false;
|
|
1105
|
+
if (iterationBudgetMs && iterationBudgetMs > 0) {
|
|
1106
|
+
iterationTimeoutId = setTimeout(() => {
|
|
1107
|
+
iterationTimedOut = true;
|
|
1108
|
+
}, iterationBudgetMs);
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
const totalRemainingMs = totalBudgetMs
|
|
1112
|
+
? Math.max(0, totalBudgetMs - (Date.now() - startTimeMs))
|
|
1113
|
+
: null;
|
|
1114
|
+
const result = await runCodex(prompt, {
|
|
1115
|
+
hardTimeoutMs: totalRemainingMs,
|
|
1116
|
+
});
|
|
1117
|
+
if (iterationTimeoutId) clearTimeout(iterationTimeoutId);
|
|
1118
|
+
lastOutput = result.output;
|
|
1119
|
+
|
|
1120
|
+
if (
|
|
1121
|
+
activeDockerConfig?.enabled &&
|
|
1122
|
+
detectDockerStorageIssue(result.output)
|
|
1123
|
+
) {
|
|
1124
|
+
fatalDockerError = result.output;
|
|
1125
|
+
printDockerStorageHint(result.output);
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
if (result.timedOut) {
|
|
1130
|
+
totalTimeLimitHit = true;
|
|
1131
|
+
notes.push(
|
|
1132
|
+
`Total time limit exceeded during iteration ${iteration} (limit: ${maxTotalSeconds}s).`,
|
|
1133
|
+
);
|
|
1134
|
+
lastStatus = result.code ?? 1;
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
if (iterationTimedOut) {
|
|
1139
|
+
notes.push(
|
|
1140
|
+
`Iteration ${iteration} exceeded time limit (limit: ${maxIterationSeconds}s).`,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
if (hasCompletion(result.output)) {
|
|
1145
|
+
completed = true;
|
|
1146
|
+
break;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
if (iterationTimedOut) {
|
|
1150
|
+
iterationTimeLimitHit = true;
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
lastStatus = result.code ?? 0;
|
|
1155
|
+
if (lastStatus !== 0 && stopOnError) {
|
|
1156
|
+
break;
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
const progress = getTaskProgress(tasksFile);
|
|
1161
|
+
const iterationsSummary = `${iterationsUsed}/${maxIterations}`;
|
|
1162
|
+
const logContent = fs.existsSync(logPath)
|
|
1163
|
+
? fs.readFileSync(logPath, "utf8")
|
|
1164
|
+
: "";
|
|
1165
|
+
const latest = logContent ? extractLatestIteration(logContent) : null;
|
|
1166
|
+
const summaryStatus = completed
|
|
1167
|
+
? "completed"
|
|
1168
|
+
: totalTimeLimitHit || iterationTimeLimitHit
|
|
1169
|
+
? "timeout"
|
|
1170
|
+
: "incomplete";
|
|
1171
|
+
writeSummary(scratchpadPath, {
|
|
1172
|
+
status: summaryStatus,
|
|
1173
|
+
iterations: iterationsSummary,
|
|
1174
|
+
progress,
|
|
1175
|
+
latest,
|
|
1176
|
+
notes,
|
|
1177
|
+
});
|
|
1178
|
+
|
|
1179
|
+
if (!quiet && fs.existsSync(logPath)) {
|
|
1180
|
+
const log = fs.readFileSync(logPath, "utf8");
|
|
1181
|
+
if (log.trim()) {
|
|
1182
|
+
process.stdout.write(
|
|
1183
|
+
`\n--- Ralph Loop Log (${path.relative(root, logPath)}) ---\n\n`,
|
|
1184
|
+
);
|
|
1185
|
+
process.stdout.write(log);
|
|
1186
|
+
process.stdout.write("\n");
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
if (completed) {
|
|
1191
|
+
process.stdout.write(
|
|
1192
|
+
`\n${colors.green("Ralph run complete: LOOP_COMPLETE detected.")}\n`,
|
|
1193
|
+
);
|
|
1194
|
+
cleanupDockerImage(activeDockerConfig || { enabled: false });
|
|
1195
|
+
process.exit(0);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
if (fatalDockerError) {
|
|
1199
|
+
process.stderr.write(
|
|
1200
|
+
`\n${colors.red("Ralph run failed: Docker storage error.")}\n`,
|
|
1201
|
+
);
|
|
1202
|
+
cleanupDockerImage(activeDockerConfig || { enabled: false });
|
|
1203
|
+
process.exit(1);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
let reason =
|
|
1207
|
+
lastStatus !== 0 && stopOnError
|
|
1208
|
+
? `Stopped on error (exit code ${lastStatus}).`
|
|
1209
|
+
: "Max iterations reached without completion.";
|
|
1210
|
+
if (iterationTimeLimitHit) {
|
|
1211
|
+
reason = `Iteration time limit exceeded (limit: ${maxIterationSeconds}s).`;
|
|
1212
|
+
}
|
|
1213
|
+
if (totalTimeLimitHit) {
|
|
1214
|
+
reason = `Total time limit exceeded (limit: ${maxTotalSeconds}s).`;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const hint = "Review .ralph/loop-log.md for blockers and decide next steps.";
|
|
1218
|
+
|
|
1219
|
+
process.stderr.write(`\n${colors.red(`Ralph run failed: ${reason}`)}\n`);
|
|
1220
|
+
process.stderr.write(`${colors.red(hint)}\n`);
|
|
1221
|
+
cleanupDockerImage(activeDockerConfig || { enabled: false });
|
|
1222
|
+
process.exit(1);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
void main();
|