ralph-cli-sandboxed 0.2.6 → 0.2.8
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/README.md +185 -3
- package/dist/commands/docker.js +103 -2
- package/dist/commands/help.js +2 -1
- package/dist/commands/init.js +30 -2
- package/dist/commands/once.js +174 -15
- package/dist/commands/run.js +189 -18
- package/dist/config/cli-providers.json +28 -3
- package/dist/config/skills.json +12 -0
- package/dist/templates/prompts.d.ts +13 -0
- package/dist/templates/prompts.js +17 -0
- package/dist/utils/config.d.ts +6 -0
- package/dist/utils/config.js +5 -1
- package/dist/utils/notification.d.ts +28 -0
- package/dist/utils/notification.js +69 -0
- package/dist/utils/stream-json.d.ts +132 -0
- package/dist/utils/stream-json.js +662 -0
- package/docs/SECURITY.md +21 -6
- package/package.json +1 -1
package/dist/commands/once.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, appendFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
2
4
|
import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer } from "../utils/config.js";
|
|
3
|
-
import { resolvePromptVariables } from "../templates/prompts.js";
|
|
5
|
+
import { resolvePromptVariables, getCliProviders } from "../templates/prompts.js";
|
|
6
|
+
import { getStreamJsonParser } from "../utils/stream-json.js";
|
|
7
|
+
import { sendNotification } from "../utils/notification.js";
|
|
4
8
|
export async function once(args) {
|
|
5
9
|
// Parse flags
|
|
6
10
|
let debug = false;
|
|
@@ -32,16 +36,62 @@ export async function once(args) {
|
|
|
32
36
|
});
|
|
33
37
|
const paths = getPaths();
|
|
34
38
|
const cliConfig = getCliConfig(config);
|
|
35
|
-
|
|
39
|
+
// Check if stream-json output is enabled
|
|
40
|
+
const streamJsonConfig = config.docker?.asciinema?.streamJson;
|
|
41
|
+
const streamJsonEnabled = streamJsonConfig?.enabled ?? false;
|
|
42
|
+
const saveRawJson = streamJsonConfig?.saveRawJson !== false; // default true
|
|
43
|
+
const outputDir = config.docker?.asciinema?.outputDir || ".recordings";
|
|
44
|
+
// Get provider-specific streamJsonArgs (empty array if not defined)
|
|
45
|
+
// This allows providers without JSON streaming to still have output displayed
|
|
46
|
+
const providers = getCliProviders();
|
|
47
|
+
const providerConfig = config.cliProvider ? providers[config.cliProvider] : providers["claude"];
|
|
48
|
+
const streamJsonArgs = providerConfig?.streamJsonArgs ?? [];
|
|
49
|
+
console.log("Starting single ralph iteration...");
|
|
50
|
+
if (streamJsonEnabled) {
|
|
51
|
+
console.log("Stream JSON output enabled - displaying formatted Claude output");
|
|
52
|
+
if (saveRawJson) {
|
|
53
|
+
console.log(`Raw JSON logs will be saved to: ${outputDir}/`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
console.log();
|
|
36
57
|
// Build CLI arguments: config args + yolo args + model args + prompt args
|
|
37
58
|
// Use yoloArgs from config if available, otherwise default to Claude's --dangerously-skip-permissions
|
|
38
59
|
const yoloArgs = cliConfig.yoloArgs ?? ["--dangerously-skip-permissions"];
|
|
39
60
|
const promptArgs = cliConfig.promptArgs ?? ["-p"];
|
|
40
|
-
const promptValue = `@${paths.prd} @${paths.progress} ${prompt}`;
|
|
41
61
|
const cliArgs = [
|
|
42
62
|
...(cliConfig.args ?? []),
|
|
43
63
|
...yoloArgs,
|
|
44
64
|
];
|
|
65
|
+
// Build the prompt value based on whether fileArgs is configured
|
|
66
|
+
// fileArgs (e.g., ["--read"] for Aider) means files are passed as separate arguments
|
|
67
|
+
// Otherwise, use @file syntax embedded in the prompt (Claude Code style)
|
|
68
|
+
let promptValue;
|
|
69
|
+
if (cliConfig.fileArgs && cliConfig.fileArgs.length > 0) {
|
|
70
|
+
// Add files as separate arguments (e.g., --read prd.json --read progress.txt)
|
|
71
|
+
for (const fileArg of cliConfig.fileArgs) {
|
|
72
|
+
cliArgs.push(fileArg, paths.prd);
|
|
73
|
+
cliArgs.push(fileArg, paths.progress);
|
|
74
|
+
}
|
|
75
|
+
promptValue = prompt;
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
// Use @file syntax embedded in the prompt
|
|
79
|
+
promptValue = `@${paths.prd} @${paths.progress} ${prompt}`;
|
|
80
|
+
}
|
|
81
|
+
// Add stream-json output format if enabled (using provider-specific args)
|
|
82
|
+
let jsonLogPath;
|
|
83
|
+
if (streamJsonEnabled) {
|
|
84
|
+
cliArgs.push(...streamJsonArgs);
|
|
85
|
+
// Setup JSON log file if saving raw JSON
|
|
86
|
+
if (saveRawJson) {
|
|
87
|
+
const fullOutputDir = join(process.cwd(), outputDir);
|
|
88
|
+
if (!existsSync(fullOutputDir)) {
|
|
89
|
+
mkdirSync(fullOutputDir, { recursive: true });
|
|
90
|
+
}
|
|
91
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
92
|
+
jsonLogPath = join(fullOutputDir, `ralph-once-${timestamp}.jsonl`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
45
95
|
// Add model args if model is specified
|
|
46
96
|
if (model && cliConfig.modelArgs) {
|
|
47
97
|
cliArgs.push(...cliConfig.modelArgs, model);
|
|
@@ -49,19 +99,128 @@ export async function once(args) {
|
|
|
49
99
|
cliArgs.push(...promptArgs, promptValue);
|
|
50
100
|
if (debug) {
|
|
51
101
|
console.log(`[debug] ${cliConfig.command} ${cliArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}\n`);
|
|
102
|
+
if (jsonLogPath) {
|
|
103
|
+
console.log(`[debug] Saving raw JSON to: ${jsonLogPath}\n`);
|
|
104
|
+
}
|
|
52
105
|
}
|
|
106
|
+
// Create provider-specific stream-json parser
|
|
107
|
+
const streamJsonParser = getStreamJsonParser(config.cliProvider, debug);
|
|
108
|
+
// Notification options for this run
|
|
109
|
+
const notifyOptions = { command: config.notifyCommand, debug };
|
|
53
110
|
return new Promise((resolve, reject) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
let output = ""; // Accumulate output for PRD complete detection
|
|
112
|
+
if (streamJsonEnabled) {
|
|
113
|
+
// Stream JSON mode: capture stdout, parse JSON, display clean text
|
|
114
|
+
let lineBuffer = "";
|
|
115
|
+
const proc = spawn(cliConfig.command, cliArgs, {
|
|
116
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
117
|
+
});
|
|
118
|
+
proc.stdout.on("data", (data) => {
|
|
119
|
+
const chunk = data.toString();
|
|
120
|
+
lineBuffer += chunk;
|
|
121
|
+
const lines = lineBuffer.split("\n");
|
|
122
|
+
// Keep the last incomplete line in the buffer
|
|
123
|
+
lineBuffer = lines.pop() || "";
|
|
124
|
+
for (const line of lines) {
|
|
125
|
+
const trimmedLine = line.trim();
|
|
126
|
+
if (!trimmedLine)
|
|
127
|
+
continue;
|
|
128
|
+
// Check if this is a JSON line
|
|
129
|
+
if (trimmedLine.startsWith("{")) {
|
|
130
|
+
// Save raw JSON if enabled
|
|
131
|
+
if (jsonLogPath) {
|
|
132
|
+
try {
|
|
133
|
+
appendFileSync(jsonLogPath, trimmedLine + "\n");
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
// Ignore write errors
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Parse and display clean text using provider-specific parser
|
|
140
|
+
const text = streamJsonParser.parseStreamJsonLine(trimmedLine);
|
|
141
|
+
if (text) {
|
|
142
|
+
process.stdout.write(text);
|
|
143
|
+
output += text; // Accumulate for completion detection
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
else {
|
|
147
|
+
// Non-JSON line - display as-is (might be status messages, errors, etc.)
|
|
148
|
+
process.stdout.write(trimmedLine + "\n");
|
|
149
|
+
output += trimmedLine + "\n";
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
proc.on("close", async (code) => {
|
|
154
|
+
// Process any remaining buffered content
|
|
155
|
+
if (lineBuffer.trim()) {
|
|
156
|
+
const trimmedLine = lineBuffer.trim();
|
|
157
|
+
if (trimmedLine.startsWith("{")) {
|
|
158
|
+
if (jsonLogPath) {
|
|
159
|
+
try {
|
|
160
|
+
appendFileSync(jsonLogPath, trimmedLine + "\n");
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
// Ignore write errors
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const text = streamJsonParser.parseStreamJsonLine(trimmedLine);
|
|
167
|
+
if (text) {
|
|
168
|
+
process.stdout.write(text);
|
|
169
|
+
output += text;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// Non-JSON remaining content
|
|
174
|
+
process.stdout.write(trimmedLine + "\n");
|
|
175
|
+
output += trimmedLine + "\n";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Ensure final newline
|
|
179
|
+
process.stdout.write("\n");
|
|
180
|
+
// Send notification based on outcome
|
|
181
|
+
if (code !== 0) {
|
|
182
|
+
console.error(`\n${cliConfig.command} exited with code ${code}`);
|
|
183
|
+
await sendNotification("error", `Ralph: Iteration failed with exit code ${code}`, notifyOptions);
|
|
184
|
+
}
|
|
185
|
+
else if (output.includes("<promise>COMPLETE</promise>")) {
|
|
186
|
+
await sendNotification("prd_complete", undefined, notifyOptions);
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
await sendNotification("iteration_complete", undefined, notifyOptions);
|
|
190
|
+
}
|
|
191
|
+
resolve();
|
|
192
|
+
});
|
|
193
|
+
proc.on("error", (err) => {
|
|
194
|
+
reject(new Error(`Failed to start ${cliConfig.command}: ${err.message}`));
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
// Standard mode: capture stdout while passing through
|
|
199
|
+
const proc = spawn(cliConfig.command, cliArgs, {
|
|
200
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
201
|
+
});
|
|
202
|
+
proc.stdout.on("data", (data) => {
|
|
203
|
+
const chunk = data.toString();
|
|
204
|
+
output += chunk;
|
|
205
|
+
process.stdout.write(chunk);
|
|
206
|
+
});
|
|
207
|
+
proc.on("close", async (code) => {
|
|
208
|
+
// Send notification based on outcome
|
|
209
|
+
if (code !== 0) {
|
|
210
|
+
console.error(`\n${cliConfig.command} exited with code ${code}`);
|
|
211
|
+
await sendNotification("error", `Ralph: Iteration failed with exit code ${code}`, notifyOptions);
|
|
212
|
+
}
|
|
213
|
+
else if (output.includes("<promise>COMPLETE</promise>")) {
|
|
214
|
+
await sendNotification("prd_complete", undefined, notifyOptions);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
await sendNotification("iteration_complete", undefined, notifyOptions);
|
|
218
|
+
}
|
|
219
|
+
resolve();
|
|
220
|
+
});
|
|
221
|
+
proc.on("error", (err) => {
|
|
222
|
+
reject(new Error(`Failed to start ${cliConfig.command}: ${err.message}`));
|
|
223
|
+
});
|
|
224
|
+
}
|
|
66
225
|
});
|
|
67
226
|
}
|
package/dist/commands/run.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { existsSync, readFileSync, writeFileSync, unlinkSync } from "fs";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer } from "../utils/config.js";
|
|
5
|
-
import { resolvePromptVariables } from "../templates/prompts.js";
|
|
5
|
+
import { resolvePromptVariables, getCliProviders } from "../templates/prompts.js";
|
|
6
6
|
import { validatePrd, smartMerge, readPrdFile, writePrd, expandPrdFileReferences } from "../utils/prd-validator.js";
|
|
7
|
+
import { getStreamJsonParser } from "../utils/stream-json.js";
|
|
8
|
+
import { sendNotification } from "../utils/notification.js";
|
|
7
9
|
const CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
|
|
8
10
|
/**
|
|
9
11
|
* Creates a filtered PRD file containing only incomplete items (passes: false).
|
|
@@ -13,7 +15,23 @@ const CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing"
|
|
|
13
15
|
*/
|
|
14
16
|
function createFilteredPrd(prdPath, baseDir, category) {
|
|
15
17
|
const content = readFileSync(prdPath, "utf-8");
|
|
16
|
-
|
|
18
|
+
let parsed;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(content);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
console.error("\x1b[31mError: prd.json contains invalid JSON.\x1b[0m");
|
|
24
|
+
console.error("The file may have been corrupted by an LLM.\n");
|
|
25
|
+
console.error("Run \x1b[36mralph fix-prd\x1b[0m to diagnose and repair the file.");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
if (!Array.isArray(parsed)) {
|
|
29
|
+
console.error("\x1b[31mError: prd.json is corrupted - expected an array of items.\x1b[0m");
|
|
30
|
+
console.error("The file may have been modified incorrectly by an LLM.\n");
|
|
31
|
+
console.error("Run \x1b[36mralph fix-prd\x1b[0m to diagnose and repair the file.");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const items = parsed;
|
|
17
35
|
let filteredItems = items.filter(item => item.passes === false);
|
|
18
36
|
// Apply category filter if specified
|
|
19
37
|
if (category) {
|
|
@@ -42,9 +60,20 @@ function syncPassesFromTasks(tasksPath, prdPath) {
|
|
|
42
60
|
}
|
|
43
61
|
try {
|
|
44
62
|
const tasksContent = readFileSync(tasksPath, "utf-8");
|
|
45
|
-
const
|
|
63
|
+
const tasksParsed = JSON.parse(tasksContent);
|
|
64
|
+
if (!Array.isArray(tasksParsed)) {
|
|
65
|
+
console.warn("\x1b[33mWarning: prd-tasks.json is not a valid array - skipping sync.\x1b[0m");
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
const tasks = tasksParsed;
|
|
46
69
|
const prdContent = readFileSync(prdPath, "utf-8");
|
|
47
|
-
const
|
|
70
|
+
const prdParsed = JSON.parse(prdContent);
|
|
71
|
+
if (!Array.isArray(prdParsed)) {
|
|
72
|
+
console.warn("\x1b[33mWarning: prd.json is corrupted - skipping sync.\x1b[0m");
|
|
73
|
+
console.warn("Run \x1b[36mralph fix-prd\x1b[0m after this session to repair.\n");
|
|
74
|
+
return 0;
|
|
75
|
+
}
|
|
76
|
+
const prd = prdParsed;
|
|
48
77
|
let synced = 0;
|
|
49
78
|
// Find tasks that were marked as passing
|
|
50
79
|
for (const task of tasks) {
|
|
@@ -71,9 +100,11 @@ function syncPassesFromTasks(tasksPath, prdPath) {
|
|
|
71
100
|
return 0;
|
|
72
101
|
}
|
|
73
102
|
}
|
|
74
|
-
async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model) {
|
|
103
|
+
async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model, streamJson) {
|
|
75
104
|
return new Promise((resolve, reject) => {
|
|
76
105
|
let output = "";
|
|
106
|
+
let jsonLogPath;
|
|
107
|
+
let lineBuffer = ""; // Buffer for incomplete JSON lines
|
|
77
108
|
// Build CLI arguments: config args + yolo args + model args + prompt args
|
|
78
109
|
const cliArgs = [
|
|
79
110
|
...(cliConfig.args ?? []),
|
|
@@ -84,6 +115,19 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
|
|
|
84
115
|
const yoloArgs = cliConfig.yoloArgs ?? ["--dangerously-skip-permissions"];
|
|
85
116
|
cliArgs.push(...yoloArgs);
|
|
86
117
|
}
|
|
118
|
+
// Add stream-json output format if enabled (using provider-specific args)
|
|
119
|
+
if (streamJson?.enabled) {
|
|
120
|
+
cliArgs.push(...streamJson.args);
|
|
121
|
+
// Setup JSON log file if saving raw JSON
|
|
122
|
+
if (streamJson.saveRawJson) {
|
|
123
|
+
const outputDir = join(process.cwd(), streamJson.outputDir);
|
|
124
|
+
if (!existsSync(outputDir)) {
|
|
125
|
+
mkdirSync(outputDir, { recursive: true });
|
|
126
|
+
}
|
|
127
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
|
|
128
|
+
jsonLogPath = join(outputDir, `ralph-run-${timestamp}.jsonl`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
87
131
|
// Add model args if model is specified
|
|
88
132
|
if (model && cliConfig.modelArgs) {
|
|
89
133
|
cliArgs.push(...cliConfig.modelArgs, model);
|
|
@@ -91,20 +135,104 @@ async function runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig
|
|
|
91
135
|
// Use the filtered PRD (only incomplete items) for the prompt
|
|
92
136
|
// promptArgs specifies flags to use (e.g., ["-p"] for Claude, [] for positional)
|
|
93
137
|
const promptArgs = cliConfig.promptArgs ?? ["-p"];
|
|
94
|
-
|
|
138
|
+
// Build the prompt value based on whether fileArgs is configured
|
|
139
|
+
// fileArgs (e.g., ["--read"] for Aider) means files are passed as separate arguments
|
|
140
|
+
// Otherwise, use @file syntax embedded in the prompt (Claude Code style)
|
|
141
|
+
let promptValue;
|
|
142
|
+
if (cliConfig.fileArgs && cliConfig.fileArgs.length > 0) {
|
|
143
|
+
// Add files as separate arguments (e.g., --read prd-tasks.json --read progress.txt)
|
|
144
|
+
for (const fileArg of cliConfig.fileArgs) {
|
|
145
|
+
cliArgs.push(fileArg, filteredPrdPath);
|
|
146
|
+
cliArgs.push(fileArg, paths.progress);
|
|
147
|
+
}
|
|
148
|
+
promptValue = prompt;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// Use @file syntax embedded in the prompt
|
|
152
|
+
promptValue = `@${filteredPrdPath} @${paths.progress} ${prompt}`;
|
|
153
|
+
}
|
|
95
154
|
cliArgs.push(...promptArgs, promptValue);
|
|
96
155
|
if (debug) {
|
|
97
156
|
console.log(`[debug] ${cliConfig.command} ${cliArgs.map(a => a.includes(" ") ? `"${a}"` : a).join(" ")}\n`);
|
|
157
|
+
if (jsonLogPath) {
|
|
158
|
+
console.log(`[debug] Saving raw JSON to: ${jsonLogPath}\n`);
|
|
159
|
+
}
|
|
98
160
|
}
|
|
99
161
|
const proc = spawn(cliConfig.command, cliArgs, {
|
|
100
162
|
stdio: ["inherit", "pipe", "inherit"],
|
|
101
163
|
});
|
|
102
164
|
proc.stdout.on("data", (data) => {
|
|
103
165
|
const chunk = data.toString();
|
|
104
|
-
|
|
105
|
-
|
|
166
|
+
if (streamJson?.enabled) {
|
|
167
|
+
// Process stream-json output: parse JSON and display clean text
|
|
168
|
+
lineBuffer += chunk;
|
|
169
|
+
const lines = lineBuffer.split("\n");
|
|
170
|
+
// Keep the last incomplete line in the buffer
|
|
171
|
+
lineBuffer = lines.pop() || "";
|
|
172
|
+
for (const line of lines) {
|
|
173
|
+
const trimmedLine = line.trim();
|
|
174
|
+
if (!trimmedLine)
|
|
175
|
+
continue;
|
|
176
|
+
// Check if this is a JSON line
|
|
177
|
+
if (trimmedLine.startsWith("{")) {
|
|
178
|
+
// Save raw JSON if enabled
|
|
179
|
+
if (jsonLogPath) {
|
|
180
|
+
try {
|
|
181
|
+
appendFileSync(jsonLogPath, trimmedLine + "\n");
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// Ignore write errors
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Parse and display clean text using provider-specific parser
|
|
188
|
+
const text = streamJson.parser.parseStreamJsonLine(trimmedLine);
|
|
189
|
+
if (text) {
|
|
190
|
+
process.stdout.write(text);
|
|
191
|
+
output += text; // Accumulate parsed text for completion detection
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
// Non-JSON line - display as-is (might be status messages, errors, etc.)
|
|
196
|
+
process.stdout.write(trimmedLine + "\n");
|
|
197
|
+
output += trimmedLine + "\n";
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
// Standard output: pass through as-is
|
|
203
|
+
output += chunk;
|
|
204
|
+
process.stdout.write(chunk);
|
|
205
|
+
}
|
|
106
206
|
});
|
|
107
207
|
proc.on("close", (code) => {
|
|
208
|
+
// Process any remaining buffered content
|
|
209
|
+
if (streamJson?.enabled && lineBuffer.trim()) {
|
|
210
|
+
const trimmedLine = lineBuffer.trim();
|
|
211
|
+
if (trimmedLine.startsWith("{")) {
|
|
212
|
+
if (jsonLogPath) {
|
|
213
|
+
try {
|
|
214
|
+
appendFileSync(jsonLogPath, trimmedLine + "\n");
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Ignore write errors
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const text = streamJson.parser.parseStreamJsonLine(trimmedLine);
|
|
221
|
+
if (text) {
|
|
222
|
+
process.stdout.write(text);
|
|
223
|
+
output += text;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// Non-JSON remaining content
|
|
228
|
+
process.stdout.write(trimmedLine + "\n");
|
|
229
|
+
output += trimmedLine + "\n";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// Ensure final newline for clean output
|
|
233
|
+
if (streamJson?.enabled) {
|
|
234
|
+
process.stdout.write("\n");
|
|
235
|
+
}
|
|
108
236
|
resolve({ exitCode: code ?? 0, output });
|
|
109
237
|
});
|
|
110
238
|
proc.on("error", (err) => {
|
|
@@ -142,7 +270,23 @@ function formatElapsedTime(startTime, endTime) {
|
|
|
142
270
|
*/
|
|
143
271
|
function countPrdItems(prdPath, category) {
|
|
144
272
|
const content = readFileSync(prdPath, "utf-8");
|
|
145
|
-
|
|
273
|
+
let parsed;
|
|
274
|
+
try {
|
|
275
|
+
parsed = JSON.parse(content);
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
console.error("\x1b[31mError: prd.json contains invalid JSON.\x1b[0m");
|
|
279
|
+
console.error("The file may have been corrupted by an LLM.\n");
|
|
280
|
+
console.error("Run \x1b[36mralph fix-prd\x1b[0m to diagnose and repair the file.");
|
|
281
|
+
process.exit(1);
|
|
282
|
+
}
|
|
283
|
+
if (!Array.isArray(parsed)) {
|
|
284
|
+
console.error("\x1b[31mError: prd.json is corrupted - expected an array of items.\x1b[0m");
|
|
285
|
+
console.error("The file may have been modified incorrectly by an LLM.\n");
|
|
286
|
+
console.error("Run \x1b[36mralph fix-prd\x1b[0m to diagnose and repair the file.");
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
const items = parsed;
|
|
146
290
|
let filteredItems = items;
|
|
147
291
|
if (category) {
|
|
148
292
|
filteredItems = items.filter(item => item.category === category);
|
|
@@ -265,6 +409,21 @@ export async function run(args) {
|
|
|
265
409
|
});
|
|
266
410
|
const paths = getPaths();
|
|
267
411
|
const cliConfig = getCliConfig(config);
|
|
412
|
+
// Check if stream-json output is enabled
|
|
413
|
+
const streamJsonConfig = config.docker?.asciinema?.streamJson;
|
|
414
|
+
// Get provider-specific streamJsonArgs, falling back to Claude's defaults
|
|
415
|
+
const providers = getCliProviders();
|
|
416
|
+
const providerConfig = config.cliProvider ? providers[config.cliProvider] : providers["claude"];
|
|
417
|
+
// Only use provider's streamJsonArgs if defined, otherwise empty array (no special args)
|
|
418
|
+
// This allows providers without JSON streaming to still have output displayed
|
|
419
|
+
const streamJsonArgs = providerConfig?.streamJsonArgs ?? [];
|
|
420
|
+
const streamJson = streamJsonConfig?.enabled ? {
|
|
421
|
+
enabled: true,
|
|
422
|
+
saveRawJson: streamJsonConfig.saveRawJson !== false, // default true
|
|
423
|
+
outputDir: config.docker?.asciinema?.outputDir || ".recordings",
|
|
424
|
+
args: streamJsonArgs,
|
|
425
|
+
parser: getStreamJsonParser(config.cliProvider, debug),
|
|
426
|
+
} : undefined;
|
|
268
427
|
// Progress tracking: stop only if no tasks complete after N iterations
|
|
269
428
|
const MAX_ITERATIONS_WITHOUT_PROGRESS = 3;
|
|
270
429
|
// Get requested iteration count (may be adjusted dynamically)
|
|
@@ -285,6 +444,12 @@ export async function run(args) {
|
|
|
285
444
|
if (category) {
|
|
286
445
|
console.log(`Filtering PRD items by category: ${category}`);
|
|
287
446
|
}
|
|
447
|
+
if (streamJson?.enabled) {
|
|
448
|
+
console.log("Stream JSON output enabled - displaying formatted Claude output");
|
|
449
|
+
if (streamJson.saveRawJson) {
|
|
450
|
+
console.log(`Raw JSON logs will be saved to: ${streamJson.outputDir}/`);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
288
453
|
console.log();
|
|
289
454
|
// Track temp file for cleanup
|
|
290
455
|
let filteredPrdPath = null;
|
|
@@ -378,10 +543,15 @@ export async function run(args) {
|
|
|
378
543
|
console.log("PRD COMPLETE - All features already implemented!");
|
|
379
544
|
}
|
|
380
545
|
console.log("=".repeat(50));
|
|
546
|
+
// Send notification for PRD completion
|
|
547
|
+
await sendNotification("prd_complete", undefined, {
|
|
548
|
+
command: config.notifyCommand,
|
|
549
|
+
debug,
|
|
550
|
+
});
|
|
381
551
|
break;
|
|
382
552
|
}
|
|
383
553
|
}
|
|
384
|
-
const { exitCode, output } = await runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model);
|
|
554
|
+
const { exitCode, output } = await runIteration(prompt, paths, sandboxed, filteredPrdPath, cliConfig, debug, model, streamJson);
|
|
385
555
|
// Sync any completed items from prd-tasks.json back to prd.json
|
|
386
556
|
// This catches cases where the LLM updated prd-tasks.json instead of prd.json
|
|
387
557
|
syncPassesFromTasks(filteredPrdPath, paths.prd);
|
|
@@ -415,6 +585,8 @@ export async function run(args) {
|
|
|
415
585
|
console.log(`(No tasks completed and no new tasks added)`);
|
|
416
586
|
console.log(`Status: ${progressCounts.complete}/${progressCounts.total} complete, ${progressCounts.incomplete} remaining.`);
|
|
417
587
|
console.log("Check the PRD and task definitions for issues.");
|
|
588
|
+
// Send notification about stopped run
|
|
589
|
+
await sendNotification("run_stopped", `Ralph: Run stopped - no progress after ${MAX_ITERATIONS_WITHOUT_PROGRESS} iterations. ${progressCounts.incomplete} tasks remaining.`, { command: config.notifyCommand, debug });
|
|
418
590
|
break;
|
|
419
591
|
}
|
|
420
592
|
}
|
|
@@ -432,6 +604,8 @@ export async function run(args) {
|
|
|
432
604
|
console.error(`\nStopping: ${cliConfig.command} failed ${consecutiveFailures} times in a row with exit code ${exitCode}.`);
|
|
433
605
|
console.error("This usually indicates a configuration error (e.g., missing API key).");
|
|
434
606
|
console.error("Please check your CLI configuration and try again.");
|
|
607
|
+
// Send notification about error
|
|
608
|
+
await sendNotification("error", `Ralph: CLI failed ${consecutiveFailures} times with exit code ${exitCode}. Check configuration.`, { command: config.notifyCommand, debug });
|
|
435
609
|
break;
|
|
436
610
|
}
|
|
437
611
|
console.log("Continuing to next iteration...");
|
|
@@ -472,13 +646,10 @@ export async function run(args) {
|
|
|
472
646
|
}
|
|
473
647
|
console.log("=".repeat(50));
|
|
474
648
|
// Send notification if configured
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// Notification command not available, ignore
|
|
480
|
-
});
|
|
481
|
-
}
|
|
649
|
+
await sendNotification("prd_complete", undefined, {
|
|
650
|
+
command: config.notifyCommand,
|
|
651
|
+
debug,
|
|
652
|
+
});
|
|
482
653
|
break;
|
|
483
654
|
}
|
|
484
655
|
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
"name": "Claude Code",
|
|
5
5
|
"description": "Anthropic's Claude Code CLI",
|
|
6
6
|
"command": "claude",
|
|
7
|
-
"defaultArgs": [
|
|
7
|
+
"defaultArgs": [],
|
|
8
8
|
"yoloArgs": ["--dangerously-skip-permissions"],
|
|
9
9
|
"promptArgs": ["-p"],
|
|
10
|
+
"streamJsonArgs": ["--output-format", "stream-json", "--verbose", "--print"],
|
|
10
11
|
"docker": {
|
|
11
12
|
"install": "# Install Claude Code CLI (as node user so it installs to /home/node/.local/bin)\nRUN su - node -c 'curl -fsSL https://claude.ai/install.sh | bash' \\\n && echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> /home/node/.zshrc"
|
|
12
13
|
},
|
|
@@ -25,6 +26,7 @@
|
|
|
25
26
|
"defaultArgs": ["--yes"],
|
|
26
27
|
"yoloArgs": ["--yes-always"],
|
|
27
28
|
"promptArgs": ["--message"],
|
|
29
|
+
"fileArgs": ["--read"],
|
|
28
30
|
"docker": {
|
|
29
31
|
"install": "# Install Aider (requires Python)\nRUN apt-get update && apt-get install -y python3 python3-pip && rm -rf /var/lib/apt/lists/* \\\n && pip3 install --break-system-packages --no-cache-dir aider-chat",
|
|
30
32
|
"note": "Check 'aider --help' for available flags"
|
|
@@ -43,7 +45,8 @@
|
|
|
43
45
|
"command": "codex",
|
|
44
46
|
"defaultArgs": ["--approval-mode", "suggest"],
|
|
45
47
|
"yoloArgs": ["--approval-mode", "full-auto"],
|
|
46
|
-
"promptArgs": [],
|
|
48
|
+
"promptArgs": ["exec"],
|
|
49
|
+
"streamJsonArgs": ["--json"],
|
|
47
50
|
"docker": {
|
|
48
51
|
"install": "# Install OpenAI Codex CLI\nRUN npm install -g @openai/codex",
|
|
49
52
|
"note": "Check 'codex --help' for available flags"
|
|
@@ -63,6 +66,7 @@
|
|
|
63
66
|
"defaultArgs": [],
|
|
64
67
|
"yoloArgs": ["-y"],
|
|
65
68
|
"promptArgs": [],
|
|
69
|
+
"streamJsonArgs": ["--output-format", "json"],
|
|
66
70
|
"docker": {
|
|
67
71
|
"install": "# Install Google Gemini CLI\nRUN npm install -g @google/gemini-cli",
|
|
68
72
|
"note": "Check 'gemini --help' for available flags"
|
|
@@ -80,8 +84,9 @@
|
|
|
80
84
|
"description": "Open source AI coding agent for the terminal",
|
|
81
85
|
"command": "opencode",
|
|
82
86
|
"defaultArgs": [],
|
|
83
|
-
"yoloArgs": [
|
|
87
|
+
"yoloArgs": [],
|
|
84
88
|
"promptArgs": ["run"],
|
|
89
|
+
"streamJsonArgs": ["--format", "json"],
|
|
85
90
|
"docker": {
|
|
86
91
|
"install": "# Install OpenCode (as node user)\nRUN su - node -c 'curl -fsSL https://opencode.ai/install | bash' \\\n && echo 'export PATH=\"$HOME/.opencode/bin:$PATH\"' >> /home/node/.zshrc",
|
|
87
92
|
"note": "Check 'opencode --help' for available flags"
|
|
@@ -111,6 +116,26 @@
|
|
|
111
116
|
},
|
|
112
117
|
"modelArgs": ["--model"]
|
|
113
118
|
},
|
|
119
|
+
"goose": {
|
|
120
|
+
"name": "Goose CLI",
|
|
121
|
+
"description": "Block's Goose AI coding agent",
|
|
122
|
+
"command": "goose",
|
|
123
|
+
"defaultArgs": [],
|
|
124
|
+
"yoloArgs": [],
|
|
125
|
+
"promptArgs": ["run", "--text"],
|
|
126
|
+
"streamJsonArgs": ["--output-format", "stream-json"],
|
|
127
|
+
"docker": {
|
|
128
|
+
"install": "# Install Goose CLI (requires Python)\nRUN apt-get update && apt-get install -y python3 python3-pip python3-venv && rm -rf /var/lib/apt/lists/* \\\n && pip3 install --break-system-packages --no-cache-dir goose-ai",
|
|
129
|
+
"note": "Check 'goose --help' for available flags"
|
|
130
|
+
},
|
|
131
|
+
"envVars": ["OPENAI_API_KEY", "ANTHROPIC_API_KEY"],
|
|
132
|
+
"credentialMount": null,
|
|
133
|
+
"modelConfig": {
|
|
134
|
+
"envVar": "GOOSE_PROVIDER",
|
|
135
|
+
"note": "Set provider via GOOSE_PROVIDER env var"
|
|
136
|
+
},
|
|
137
|
+
"modelArgs": ["--model"]
|
|
138
|
+
},
|
|
114
139
|
"custom": {
|
|
115
140
|
"name": "Custom CLI",
|
|
116
141
|
"description": "Configure your own AI CLI tool",
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"skills": {
|
|
3
|
+
"swift": [
|
|
4
|
+
{
|
|
5
|
+
"name": "swift-main-naming",
|
|
6
|
+
"description": "Prevents naming files main.swift when using @main attribute",
|
|
7
|
+
"instructions": "IMPORTANT: In Swift, files containing the @main attribute MUST NOT be named main.swift.\n\nWhen the @main attribute is used (e.g., @main struct App), Swift automatically generates an entry point. If the file is also named main.swift, Swift treats it as having a manual entry point, causing a conflict.\n\nRULES:\n- Never name a file main.swift if it contains @main attribute\n- Use descriptive names like App.swift, MyApp.swift, or the actual type name\n- If you encounter a main.swift with @main, rename it to match the type (e.g., struct MyApp -> MyApp.swift)\n\nEXAMPLES:\n\nBAD:\n```\n// main.swift\n@main\nstruct App {\n static func main() { ... }\n}\n```\n\nGOOD:\n```\n// App.swift\n@main\nstruct App {\n static func main() { ... }\n}\n```",
|
|
8
|
+
"userInvocable": false
|
|
9
|
+
}
|
|
10
|
+
]
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -35,6 +35,8 @@ export interface CliProviderConfig {
|
|
|
35
35
|
yoloArgs: string[];
|
|
36
36
|
promptArgs: string[];
|
|
37
37
|
modelArgs?: string[];
|
|
38
|
+
fileArgs?: string[];
|
|
39
|
+
streamJsonArgs?: string[];
|
|
38
40
|
docker: {
|
|
39
41
|
install: string;
|
|
40
42
|
};
|
|
@@ -48,9 +50,20 @@ export interface CliProviderConfig {
|
|
|
48
50
|
interface CliProvidersJson {
|
|
49
51
|
providers: Record<string, CliProviderConfig>;
|
|
50
52
|
}
|
|
53
|
+
export interface SkillDefinition {
|
|
54
|
+
name: string;
|
|
55
|
+
description: string;
|
|
56
|
+
instructions: string;
|
|
57
|
+
userInvocable?: boolean;
|
|
58
|
+
}
|
|
59
|
+
interface SkillsJson {
|
|
60
|
+
skills: Record<string, SkillDefinition[]>;
|
|
61
|
+
}
|
|
51
62
|
export declare function getLanguagesJson(): LanguagesJson;
|
|
52
63
|
export declare function getCliProvidersJson(): CliProvidersJson;
|
|
53
64
|
export declare function getCliProviders(): Record<string, CliProviderConfig>;
|
|
65
|
+
export declare function getSkillsJson(): SkillsJson;
|
|
66
|
+
export declare function getSkillsForLanguage(language: string): SkillDefinition[];
|
|
54
67
|
export declare function getLanguages(): Record<string, LanguageConfig>;
|
|
55
68
|
export declare const LANGUAGES: Record<string, LanguageConfig>;
|
|
56
69
|
export declare function generatePromptTemplate(): string;
|