@lnilluv/pi-ralph-loop 0.2.1 → 0.3.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/.github/workflows/ci.yml +5 -2
- package/.github/workflows/release.yml +7 -4
- package/README.md +64 -16
- package/package.json +12 -4
- package/src/index.ts +433 -245
- package/src/ralph.ts +642 -0
- package/tests/ralph.test.ts +470 -0
- package/tsconfig.json +3 -2
package/src/index.ts
CHANGED
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
import { parse as parseYaml } from "yaml";
|
|
2
1
|
import { minimatch } from "minimatch";
|
|
3
|
-
import { readFileSync,
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative } from "node:path";
|
|
5
4
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import {
|
|
6
|
+
buildMissionBrief,
|
|
7
|
+
classifyIdleState,
|
|
8
|
+
generateDraft,
|
|
9
|
+
inspectExistingTarget,
|
|
10
|
+
inspectRepo,
|
|
11
|
+
parseCommandArgs,
|
|
12
|
+
parseRalphMarkdown,
|
|
13
|
+
planTaskDraftTarget,
|
|
14
|
+
renderIterationPrompt,
|
|
15
|
+
renderRalphBody,
|
|
16
|
+
shouldResetFailCount,
|
|
17
|
+
shouldStopForCompletionPromise,
|
|
18
|
+
shouldWarnForBashFailure,
|
|
19
|
+
shouldValidateExistingDraft,
|
|
20
|
+
validateDraftContent,
|
|
21
|
+
validateFrontmatter as validateFrontmatterMessage,
|
|
22
|
+
createSiblingTarget,
|
|
23
|
+
findBlockedCommandPattern,
|
|
24
|
+
} from "./ralph.ts";
|
|
25
|
+
import type { CommandDef, CommandOutput, DraftTarget, Frontmatter } from "./ralph.ts";
|
|
6
26
|
|
|
7
|
-
type CommandDef = { name: string; run: string; timeout: number };
|
|
8
|
-
type Frontmatter = {
|
|
9
|
-
commands: CommandDef[];
|
|
10
|
-
maxIterations: number;
|
|
11
|
-
timeout: number;
|
|
12
|
-
completionPromise?: string;
|
|
13
|
-
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
14
|
-
};
|
|
15
|
-
type ParsedRalph = { frontmatter: Frontmatter; body: string };
|
|
16
|
-
type CommandOutput = { name: string; output: string };
|
|
17
27
|
type LoopState = {
|
|
18
28
|
active: boolean;
|
|
19
29
|
ralphPath: string;
|
|
@@ -36,94 +46,35 @@ type PersistedLoopState = {
|
|
|
36
46
|
stopRequested?: boolean;
|
|
37
47
|
};
|
|
38
48
|
|
|
39
|
-
function
|
|
40
|
-
return
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function parseRalphMd(filePath: string): ParsedRalph {
|
|
44
|
-
let raw = readFileSync(filePath, "utf8");
|
|
45
|
-
raw = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
46
|
-
const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
47
|
-
if (!match) return { frontmatter: defaultFrontmatter(), body: raw };
|
|
48
|
-
|
|
49
|
-
const yaml = (parseYaml(match[1]) ?? {}) as Record<string, any>;
|
|
50
|
-
const commands: CommandDef[] = Array.isArray(yaml.commands)
|
|
51
|
-
? yaml.commands.map((c: Record<string, any>) => ({ name: String(c.name ?? ""), run: String(c.run ?? ""), timeout: Number(c.timeout ?? 60) }))
|
|
52
|
-
: [];
|
|
53
|
-
const guardrails = (yaml.guardrails ?? {}) as Record<string, any>;
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
frontmatter: {
|
|
57
|
-
commands,
|
|
58
|
-
maxIterations: Number(yaml.max_iterations ?? 50),
|
|
59
|
-
timeout: Number(yaml.timeout ?? 300),
|
|
60
|
-
completionPromise:
|
|
61
|
-
typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
|
|
62
|
-
guardrails: {
|
|
63
|
-
blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands.map((p: unknown) => String(p)) : [],
|
|
64
|
-
protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files.map((p: unknown) => String(p)) : [],
|
|
65
|
-
},
|
|
66
|
-
},
|
|
67
|
-
body: match[2] ?? "",
|
|
68
|
-
};
|
|
49
|
+
function parseRalphMd(filePath: string) {
|
|
50
|
+
return parseRalphMarkdown(readFileSync(filePath, "utf8"));
|
|
69
51
|
}
|
|
70
52
|
|
|
71
53
|
function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
|
|
72
|
-
|
|
73
|
-
|
|
54
|
+
const error = validateFrontmatterMessage(fm);
|
|
55
|
+
if (error) {
|
|
56
|
+
ctx.ui.notify(error, "error");
|
|
74
57
|
return false;
|
|
75
58
|
}
|
|
76
|
-
if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
|
|
77
|
-
ctx.ui.notify("Invalid timeout: must be a positive finite number", "error");
|
|
78
|
-
return false;
|
|
79
|
-
}
|
|
80
|
-
for (const pattern of fm.guardrails.blockCommands) {
|
|
81
|
-
try { new RegExp(pattern); } catch {
|
|
82
|
-
ctx.ui.notify(`Invalid block_commands regex: ${pattern}`, "error");
|
|
83
|
-
return false;
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
for (const cmd of fm.commands) {
|
|
87
|
-
if (!cmd.name.trim()) {
|
|
88
|
-
ctx.ui.notify("Invalid command: name is required", "error");
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if (!cmd.run.trim()) {
|
|
92
|
-
ctx.ui.notify(`Invalid command ${cmd.name}: run is required`, "error");
|
|
93
|
-
return false;
|
|
94
|
-
}
|
|
95
|
-
if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
|
|
96
|
-
ctx.ui.notify(`Invalid command ${cmd.name}: timeout must be positive`, "error");
|
|
97
|
-
return false;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
59
|
return true;
|
|
101
60
|
}
|
|
102
61
|
|
|
103
|
-
function
|
|
104
|
-
const target = args.trim() || ".";
|
|
105
|
-
const abs = resolve(cwd, target);
|
|
106
|
-
if (existsSync(abs) && abs.endsWith(".md")) return abs;
|
|
107
|
-
if (existsSync(join(abs, "RALPH.md"))) return join(abs, "RALPH.md");
|
|
108
|
-
throw new Error(`No RALPH.md found at ${abs}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
|
|
112
|
-
const map = new Map(outputs.map((o) => [o.name, o.output]));
|
|
113
|
-
return body
|
|
114
|
-
.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
|
|
115
|
-
.replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
|
|
116
|
-
.replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<CommandOutput[]> {
|
|
62
|
+
export async function runCommands(commands: CommandDef[], blockPatterns: string[], pi: ExtensionAPI): Promise<CommandOutput[]> {
|
|
120
63
|
const results: CommandOutput[] = [];
|
|
121
64
|
for (const cmd of commands) {
|
|
65
|
+
const blockedPattern = findBlockedCommandPattern(cmd.run, blockPatterns);
|
|
66
|
+
if (blockedPattern) {
|
|
67
|
+
results.push({ name: cmd.name, output: `[blocked by guardrail: ${blockedPattern}]` });
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
122
71
|
try {
|
|
123
72
|
const result = await pi.exec("bash", ["-c", cmd.run], { timeout: cmd.timeout * 1000 });
|
|
124
|
-
results.push(
|
|
125
|
-
|
|
126
|
-
|
|
73
|
+
results.push(
|
|
74
|
+
result.killed
|
|
75
|
+
? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
|
|
76
|
+
: { name: cmd.name, output: (result.stdout + result.stderr).trim() },
|
|
77
|
+
);
|
|
127
78
|
} catch (err) {
|
|
128
79
|
const message = err instanceof Error ? err.message : String(err);
|
|
129
80
|
results.push({ name: cmd.name, output: `[error: ${message}]` });
|
|
@@ -133,7 +84,18 @@ async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<Co
|
|
|
133
84
|
}
|
|
134
85
|
|
|
135
86
|
function defaultLoopState(): LoopState {
|
|
136
|
-
return {
|
|
87
|
+
return {
|
|
88
|
+
active: false,
|
|
89
|
+
ralphPath: "",
|
|
90
|
+
iteration: 0,
|
|
91
|
+
maxIterations: 50,
|
|
92
|
+
timeout: 300,
|
|
93
|
+
completionPromise: undefined,
|
|
94
|
+
stopRequested: false,
|
|
95
|
+
iterationSummaries: [],
|
|
96
|
+
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
97
|
+
loopSessionFile: undefined,
|
|
98
|
+
};
|
|
137
99
|
}
|
|
138
100
|
|
|
139
101
|
function readPersistedLoopState(ctx: any): PersistedLoopState | undefined {
|
|
@@ -151,6 +113,131 @@ function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
|
|
|
151
113
|
pi.appendEntry("ralph-loop-state", data);
|
|
152
114
|
}
|
|
153
115
|
|
|
116
|
+
function writeDraftFile(ralphPath: string, content: string) {
|
|
117
|
+
mkdirSync(dirname(ralphPath), { recursive: true });
|
|
118
|
+
writeFileSync(ralphPath, content, "utf8");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function displayPath(cwd: string, filePath: string): string {
|
|
122
|
+
const rel = relative(cwd, filePath);
|
|
123
|
+
return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function promptForTask(ctx: any, title: string, placeholder: string): Promise<string | undefined> {
|
|
127
|
+
if (!ctx.hasUI) return undefined;
|
|
128
|
+
const value = await ctx.ui.input(title, placeholder);
|
|
129
|
+
const trimmed = value?.trim();
|
|
130
|
+
return trimmed ? trimmed : undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function reviewDraft(plan: ReturnType<typeof generateDraft>, mode: "run" | "draft", ctx: any): Promise<{ action: "start" | "save" | "cancel"; content: string }> {
|
|
134
|
+
let content = plan.content;
|
|
135
|
+
|
|
136
|
+
while (true) {
|
|
137
|
+
const nextPlan = { ...plan, content };
|
|
138
|
+
const contentError = validateDraftContent(content);
|
|
139
|
+
const options = contentError
|
|
140
|
+
? ["Open RALPH.md", "Cancel"]
|
|
141
|
+
: mode === "run"
|
|
142
|
+
? ["Start", "Open RALPH.md", "Cancel"]
|
|
143
|
+
: ["Save draft", "Open RALPH.md", "Cancel"];
|
|
144
|
+
const choice = await ctx.ui.select(buildMissionBrief(nextPlan), options);
|
|
145
|
+
|
|
146
|
+
if (!choice || choice === "Cancel") {
|
|
147
|
+
return { action: "cancel", content };
|
|
148
|
+
}
|
|
149
|
+
if (choice === "Open RALPH.md") {
|
|
150
|
+
const edited = await ctx.ui.editor("Edit RALPH.md", content);
|
|
151
|
+
if (typeof edited === "string") content = edited;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (contentError) {
|
|
155
|
+
ctx.ui.notify(`Invalid RALPH.md: ${contentError}`, "error");
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (choice === "Save draft") {
|
|
159
|
+
return { action: "save", content };
|
|
160
|
+
}
|
|
161
|
+
return { action: "start", content };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function editExistingDraft(ralphPath: string, ctx: any, saveMessage = "Saved RALPH.md") {
|
|
166
|
+
if (!ctx.hasUI) {
|
|
167
|
+
ctx.ui.notify(`Use ${displayPath(ctx.cwd, ralphPath)} in an interactive session to edit the draft.`, "warning");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let content = readFileSync(ralphPath, "utf8");
|
|
172
|
+
const strictValidation = shouldValidateExistingDraft(content);
|
|
173
|
+
while (true) {
|
|
174
|
+
const edited = await ctx.ui.editor("Edit RALPH.md", content);
|
|
175
|
+
if (typeof edited !== "string") return;
|
|
176
|
+
|
|
177
|
+
if (strictValidation) {
|
|
178
|
+
const error = validateDraftContent(edited);
|
|
179
|
+
if (error) {
|
|
180
|
+
ctx.ui.notify(`Invalid RALPH.md: ${error}`, "error");
|
|
181
|
+
content = edited;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (edited !== content) {
|
|
187
|
+
writeDraftFile(ralphPath, edited);
|
|
188
|
+
ctx.ui.notify(saveMessage, "info");
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function chooseRecoveryMode(
|
|
195
|
+
input: string,
|
|
196
|
+
dirPath: string,
|
|
197
|
+
ctx: any,
|
|
198
|
+
allowTaskFallback = true,
|
|
199
|
+
): Promise<"draft-path" | "task" | "cancel"> {
|
|
200
|
+
const options = allowTaskFallback ? ["Draft in that folder", "Treat as task text", "Cancel"] : ["Draft in that folder", "Cancel"];
|
|
201
|
+
const choice = await ctx.ui.select(`No RALPH.md in ${displayPath(ctx.cwd, dirPath)}.`, options);
|
|
202
|
+
if (choice === "Draft in that folder") return "draft-path";
|
|
203
|
+
if (choice === "Treat as task text") return "task";
|
|
204
|
+
return "cancel";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function chooseConflictTarget(commandName: "ralph" | "ralph-draft", task: string, target: DraftTarget, ctx: any): Promise<{ action: "run-existing" | "open-existing" | "draft-target" | "cancel"; target?: DraftTarget }> {
|
|
208
|
+
const hasExistingDraft = existsSync(target.ralphPath);
|
|
209
|
+
const title = hasExistingDraft
|
|
210
|
+
? `Found an existing RALPH at ${displayPath(ctx.cwd, target.ralphPath)} for “${task}”.`
|
|
211
|
+
: `Found an occupied draft directory at ${displayPath(ctx.cwd, target.dirPath)} for “${task}”.`;
|
|
212
|
+
const options =
|
|
213
|
+
commandName === "ralph"
|
|
214
|
+
? hasExistingDraft
|
|
215
|
+
? ["Run existing", "Open existing RALPH.md", "Create sibling", "Cancel"]
|
|
216
|
+
: ["Create sibling", "Cancel"]
|
|
217
|
+
: hasExistingDraft
|
|
218
|
+
? ["Open existing RALPH.md", "Create sibling", "Cancel"]
|
|
219
|
+
: ["Create sibling", "Cancel"];
|
|
220
|
+
const choice = await ctx.ui.select(title, options);
|
|
221
|
+
|
|
222
|
+
if (!choice || choice === "Cancel") return { action: "cancel" };
|
|
223
|
+
if (choice === "Run existing") return { action: "run-existing" };
|
|
224
|
+
if (choice === "Open existing RALPH.md") return { action: "open-existing" };
|
|
225
|
+
return { action: "draft-target", target: createSiblingTarget(ctx.cwd, target.slug) };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function draftFromTask(commandName: "ralph" | "ralph-draft", task: string, target: DraftTarget, ctx: any): Promise<string | undefined> {
|
|
229
|
+
const plan = generateDraft(task, target, inspectRepo(ctx.cwd));
|
|
230
|
+
const review = await reviewDraft(plan, commandName === "ralph" ? "run" : "draft", ctx);
|
|
231
|
+
if (review.action === "cancel") return undefined;
|
|
232
|
+
|
|
233
|
+
writeDraftFile(target.ralphPath, review.content);
|
|
234
|
+
if (review.action === "save") {
|
|
235
|
+
ctx.ui.notify(`Draft saved to ${displayPath(ctx.cwd, target.ralphPath)}`, "info");
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
return target.ralphPath;
|
|
239
|
+
}
|
|
240
|
+
|
|
154
241
|
let loopState: LoopState = defaultLoopState();
|
|
155
242
|
|
|
156
243
|
export default function (pi: ExtensionAPI) {
|
|
@@ -161,6 +248,249 @@ export default function (pi: ExtensionAPI) {
|
|
|
161
248
|
return state?.active === true && state.sessionFile === sessionFile;
|
|
162
249
|
};
|
|
163
250
|
|
|
251
|
+
async function startRalphLoop(ralphPath: string, ctx: any) {
|
|
252
|
+
let name: string;
|
|
253
|
+
try {
|
|
254
|
+
const raw = readFileSync(ralphPath, "utf8");
|
|
255
|
+
if (shouldValidateExistingDraft(raw)) {
|
|
256
|
+
const draftError = validateDraftContent(raw);
|
|
257
|
+
if (draftError) {
|
|
258
|
+
ctx.ui.notify(`Invalid RALPH.md: ${draftError}`, "error");
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const { frontmatter } = parseRalphMd(ralphPath);
|
|
263
|
+
if (!validateFrontmatter(frontmatter, ctx)) return;
|
|
264
|
+
name = basename(dirname(ralphPath));
|
|
265
|
+
loopState = {
|
|
266
|
+
active: true,
|
|
267
|
+
ralphPath,
|
|
268
|
+
iteration: 0,
|
|
269
|
+
maxIterations: frontmatter.maxIterations,
|
|
270
|
+
timeout: frontmatter.timeout,
|
|
271
|
+
completionPromise: frontmatter.completionPromise,
|
|
272
|
+
stopRequested: false,
|
|
273
|
+
iterationSummaries: [],
|
|
274
|
+
guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
|
|
275
|
+
loopSessionFile: undefined,
|
|
276
|
+
};
|
|
277
|
+
} catch (err) {
|
|
278
|
+
ctx.ui.notify(String(err), "error");
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
|
|
282
|
+
|
|
283
|
+
try {
|
|
284
|
+
iterationLoop: for (let i = 1; i <= loopState.maxIterations; i++) {
|
|
285
|
+
if (loopState.stopRequested) break;
|
|
286
|
+
const persistedBefore = readPersistedLoopState(ctx);
|
|
287
|
+
if (persistedBefore?.active && persistedBefore.stopRequested) {
|
|
288
|
+
loopState.stopRequested = true;
|
|
289
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
loopState.iteration = i;
|
|
294
|
+
const iterStart = Date.now();
|
|
295
|
+
const { frontmatter: fm, body: rawBody } = parseRalphMd(loopState.ralphPath);
|
|
296
|
+
if (!validateFrontmatter(fm, ctx)) {
|
|
297
|
+
ctx.ui.notify(`Invalid RALPH.md on iteration ${i}, stopping loop`, "error");
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
loopState.maxIterations = fm.maxIterations;
|
|
302
|
+
loopState.timeout = fm.timeout;
|
|
303
|
+
loopState.completionPromise = fm.completionPromise;
|
|
304
|
+
loopState.guardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
|
|
305
|
+
|
|
306
|
+
const outputs = await runCommands(fm.commands, fm.guardrails.blockCommands, pi);
|
|
307
|
+
const body = renderRalphBody(rawBody, outputs, { iteration: i, name });
|
|
308
|
+
const prompt = renderIterationPrompt(body, i, loopState.maxIterations);
|
|
309
|
+
|
|
310
|
+
const prevPersisted = readPersistedLoopState(ctx);
|
|
311
|
+
if (prevPersisted?.active && prevPersisted.sessionFile === ctx.sessionManager.getSessionFile()) {
|
|
312
|
+
persistLoopState(pi, { ...prevPersisted, active: false });
|
|
313
|
+
}
|
|
314
|
+
ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
|
|
315
|
+
const prevSessionFile = loopState.loopSessionFile;
|
|
316
|
+
const { cancelled } = await ctx.newSession();
|
|
317
|
+
if (cancelled) {
|
|
318
|
+
ctx.ui.notify("Session switch cancelled, stopping loop", "warning");
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
loopState.loopSessionFile = ctx.sessionManager.getSessionFile();
|
|
323
|
+
if (shouldResetFailCount(prevSessionFile, loopState.loopSessionFile)) failCounts.delete(prevSessionFile!);
|
|
324
|
+
if (loopState.loopSessionFile) failCounts.set(loopState.loopSessionFile, 0);
|
|
325
|
+
persistLoopState(pi, {
|
|
326
|
+
active: true,
|
|
327
|
+
sessionFile: loopState.loopSessionFile,
|
|
328
|
+
iteration: loopState.iteration,
|
|
329
|
+
maxIterations: loopState.maxIterations,
|
|
330
|
+
iterationSummaries: loopState.iterationSummaries,
|
|
331
|
+
guardrails: { blockCommands: loopState.guardrails.blockCommands, protectedFiles: loopState.guardrails.protectedFiles },
|
|
332
|
+
stopRequested: false,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
pi.sendUserMessage(prompt);
|
|
336
|
+
const timeoutMs = fm.timeout * 1000;
|
|
337
|
+
let timedOut = false;
|
|
338
|
+
let idleError: Error | undefined;
|
|
339
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
+
try {
|
|
341
|
+
await Promise.race([
|
|
342
|
+
ctx.waitForIdle().catch((e: any) => {
|
|
343
|
+
idleError = e instanceof Error ? e : new Error(String(e));
|
|
344
|
+
throw e;
|
|
345
|
+
}),
|
|
346
|
+
new Promise<never>((_, reject) => {
|
|
347
|
+
timer = setTimeout(() => {
|
|
348
|
+
timedOut = true;
|
|
349
|
+
reject(new Error("timeout"));
|
|
350
|
+
}, timeoutMs);
|
|
351
|
+
}),
|
|
352
|
+
]);
|
|
353
|
+
} catch {
|
|
354
|
+
// handled below
|
|
355
|
+
}
|
|
356
|
+
if (timer) clearTimeout(timer);
|
|
357
|
+
|
|
358
|
+
const idleState = classifyIdleState(timedOut, idleError);
|
|
359
|
+
if (idleState === "timeout") {
|
|
360
|
+
ctx.ui.notify(`Iteration ${i} timed out after ${fm.timeout}s, stopping loop`, "warning");
|
|
361
|
+
break;
|
|
362
|
+
}
|
|
363
|
+
if (idleState === "error") {
|
|
364
|
+
ctx.ui.notify(`Iteration ${i} agent error: ${idleError!.message}, stopping loop`, "error");
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const elapsed = Math.round((Date.now() - iterStart) / 1000);
|
|
369
|
+
loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
|
|
370
|
+
pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
|
|
371
|
+
|
|
372
|
+
const persistedAfter = readPersistedLoopState(ctx);
|
|
373
|
+
if (persistedAfter?.active && persistedAfter.stopRequested) {
|
|
374
|
+
loopState.stopRequested = true;
|
|
375
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
376
|
+
break;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
if (fm.completionPromise) {
|
|
380
|
+
const entries = ctx.sessionManager.getEntries();
|
|
381
|
+
for (const entry of entries) {
|
|
382
|
+
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
383
|
+
const text = entry.message.content?.filter((b: any) => b.type === "text")?.map((b: any) => b.text)?.join("") ?? "";
|
|
384
|
+
if (shouldStopForCompletionPromise(text, fm.completionPromise)) {
|
|
385
|
+
ctx.ui.notify(`Completion promise matched on iteration ${i}`, "info");
|
|
386
|
+
break iterationLoop;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
|
|
396
|
+
ctx.ui.notify(`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`, "info");
|
|
397
|
+
} catch (err) {
|
|
398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
399
|
+
ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
|
|
400
|
+
} finally {
|
|
401
|
+
failCounts.clear();
|
|
402
|
+
loopState.active = false;
|
|
403
|
+
loopState.stopRequested = false;
|
|
404
|
+
loopState.loopSessionFile = undefined;
|
|
405
|
+
ctx.ui.setStatus("ralph", undefined);
|
|
406
|
+
persistLoopState(pi, { active: false });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function handleDraftCommand(commandName: "ralph" | "ralph-draft", args: string, ctx: any): Promise<string | undefined> {
|
|
411
|
+
const parsed = parseCommandArgs(args);
|
|
412
|
+
|
|
413
|
+
const resolveTaskForFolder = async (target: DraftTarget): Promise<string | undefined> => {
|
|
414
|
+
const task = await promptForTask(ctx, "What should Ralph work on in this folder?", "reverse engineer this app");
|
|
415
|
+
if (!task) return undefined;
|
|
416
|
+
return draftFromTask(commandName, task, target, ctx);
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
const handleExistingInspection = async (input: string, explicitPath = false): Promise<string | undefined> => {
|
|
420
|
+
const inspection = inspectExistingTarget(input, ctx.cwd, explicitPath);
|
|
421
|
+
switch (inspection.kind) {
|
|
422
|
+
case "run":
|
|
423
|
+
if (commandName === "ralph") return inspection.ralphPath;
|
|
424
|
+
await editExistingDraft(inspection.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, inspection.ralphPath)}`);
|
|
425
|
+
return undefined;
|
|
426
|
+
case "invalid-markdown":
|
|
427
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be run directly. ${displayPath(ctx.cwd, inspection.path)} is not runnable.`, "error");
|
|
428
|
+
return undefined;
|
|
429
|
+
case "invalid-target":
|
|
430
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be run directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
|
|
431
|
+
return undefined;
|
|
432
|
+
case "dir-without-ralph":
|
|
433
|
+
case "missing-path": {
|
|
434
|
+
if (!ctx.hasUI) {
|
|
435
|
+
ctx.ui.notify("Draft review requires an interactive session. Pass a task folder or RALPH.md path instead.", "warning");
|
|
436
|
+
return undefined;
|
|
437
|
+
}
|
|
438
|
+
const recovery = await chooseRecoveryMode(input, inspection.dirPath, ctx, !explicitPath);
|
|
439
|
+
if (recovery === "cancel") return undefined;
|
|
440
|
+
if (recovery === "task") {
|
|
441
|
+
return handleTaskFlow(input);
|
|
442
|
+
}
|
|
443
|
+
return resolveTaskForFolder({ slug: basename(inspection.dirPath), dirPath: inspection.dirPath, ralphPath: inspection.ralphPath });
|
|
444
|
+
}
|
|
445
|
+
case "not-path":
|
|
446
|
+
return handleTaskFlow(input);
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const handleTaskFlow = async (taskInput: string): Promise<string | undefined> => {
|
|
451
|
+
const task = taskInput.trim();
|
|
452
|
+
if (!task) return undefined;
|
|
453
|
+
if (!ctx.hasUI) {
|
|
454
|
+
ctx.ui.notify("Draft review requires an interactive session. Use /ralph with a task folder or RALPH.md path instead.", "warning");
|
|
455
|
+
return undefined;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let planned = planTaskDraftTarget(ctx.cwd, task);
|
|
459
|
+
if (planned.kind === "conflict") {
|
|
460
|
+
const decision = await chooseConflictTarget(commandName, task, planned.target, ctx);
|
|
461
|
+
if (decision.action === "cancel") return undefined;
|
|
462
|
+
if (decision.action === "run-existing") return planned.target.ralphPath;
|
|
463
|
+
if (decision.action === "open-existing") {
|
|
464
|
+
await editExistingDraft(planned.target.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, planned.target.ralphPath)}`);
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
planned = { kind: "draft", target: decision.target! };
|
|
468
|
+
}
|
|
469
|
+
return draftFromTask(commandName, task, planned.target, ctx);
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
if (parsed.mode === "task") {
|
|
473
|
+
return handleTaskFlow(parsed.value);
|
|
474
|
+
}
|
|
475
|
+
if (parsed.mode === "path") {
|
|
476
|
+
return handleExistingInspection(parsed.value || ".", true);
|
|
477
|
+
}
|
|
478
|
+
if (!parsed.value) {
|
|
479
|
+
const inspection = inspectExistingTarget(".", ctx.cwd);
|
|
480
|
+
if (inspection.kind === "run") {
|
|
481
|
+
if (commandName === "ralph") return inspection.ralphPath;
|
|
482
|
+
await editExistingDraft(inspection.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, inspection.ralphPath)}`);
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
if (!ctx.hasUI) {
|
|
486
|
+
ctx.ui.notify("Draft review requires an interactive session. Pass a task folder or RALPH.md path instead.", "warning");
|
|
487
|
+
return undefined;
|
|
488
|
+
}
|
|
489
|
+
return resolveTaskForFolder({ slug: basename(ctx.cwd), dirPath: ctx.cwd, ralphPath: join(ctx.cwd, "RALPH.md") });
|
|
490
|
+
}
|
|
491
|
+
return handleExistingInspection(parsed.value);
|
|
492
|
+
}
|
|
493
|
+
|
|
164
494
|
pi.on("tool_call", async (event: any, ctx: any) => {
|
|
165
495
|
if (!isLoopSession(ctx)) return;
|
|
166
496
|
const persisted = readPersistedLoopState(ctx);
|
|
@@ -168,13 +498,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
168
498
|
|
|
169
499
|
if (event.toolName === "bash") {
|
|
170
500
|
const cmd = (event.input as { command?: string }).command ?? "";
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if (new RegExp(pattern).test(cmd)) return { block: true, reason: `ralph: blocked (${pattern})` };
|
|
174
|
-
} catch {
|
|
175
|
-
// ignore malformed persisted regex
|
|
176
|
-
}
|
|
177
|
-
}
|
|
501
|
+
const blockedPattern = findBlockedCommandPattern(cmd, persisted.guardrails?.blockCommands ?? []);
|
|
502
|
+
if (blockedPattern) return { block: true, reason: `ralph: blocked (${blockedPattern})` };
|
|
178
503
|
}
|
|
179
504
|
|
|
180
505
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
@@ -202,7 +527,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
202
527
|
pi.on("tool_result", async (event: any, ctx: any) => {
|
|
203
528
|
if (!isLoopSession(ctx) || event.toolName !== "bash") return;
|
|
204
529
|
const output = event.content.map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : "")).join("");
|
|
205
|
-
if (
|
|
530
|
+
if (!shouldWarnForBashFailure(output)) return;
|
|
206
531
|
|
|
207
532
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
208
533
|
if (!sessionFile) return;
|
|
@@ -220,160 +545,23 @@ export default function (pi: ExtensionAPI) {
|
|
|
220
545
|
});
|
|
221
546
|
|
|
222
547
|
pi.registerCommand("ralph", {
|
|
223
|
-
description: "Start
|
|
548
|
+
description: "Start Ralph from a task folder or RALPH.md",
|
|
224
549
|
handler: async (args: string, ctx: any) => {
|
|
225
550
|
if (loopState.active) {
|
|
226
551
|
ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
|
|
227
552
|
return;
|
|
228
553
|
}
|
|
229
554
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
name = basename(dirname(ralphPath));
|
|
236
|
-
loopState = {
|
|
237
|
-
active: true,
|
|
238
|
-
ralphPath,
|
|
239
|
-
iteration: 0,
|
|
240
|
-
maxIterations: frontmatter.maxIterations,
|
|
241
|
-
timeout: frontmatter.timeout,
|
|
242
|
-
completionPromise: frontmatter.completionPromise,
|
|
243
|
-
stopRequested: false,
|
|
244
|
-
iterationSummaries: [],
|
|
245
|
-
guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
|
|
246
|
-
loopSessionFile: undefined,
|
|
247
|
-
};
|
|
248
|
-
} catch (err) {
|
|
249
|
-
ctx.ui.notify(String(err), "error");
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
|
|
253
|
-
|
|
254
|
-
try {
|
|
255
|
-
iterationLoop: for (let i = 1; i <= loopState.maxIterations; i++) {
|
|
256
|
-
if (loopState.stopRequested) break;
|
|
257
|
-
const persistedBefore = readPersistedLoopState(ctx);
|
|
258
|
-
if (persistedBefore?.active && persistedBefore.stopRequested) {
|
|
259
|
-
loopState.stopRequested = true;
|
|
260
|
-
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
261
|
-
break;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
loopState.iteration = i;
|
|
265
|
-
const iterStart = Date.now();
|
|
266
|
-
const { frontmatter: fm, body: rawBody } = parseRalphMd(loopState.ralphPath);
|
|
267
|
-
if (!validateFrontmatter(fm, ctx)) {
|
|
268
|
-
ctx.ui.notify(`Invalid RALPH.md on iteration ${i}, stopping loop`, "error");
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
loopState.maxIterations = fm.maxIterations;
|
|
273
|
-
loopState.timeout = fm.timeout;
|
|
274
|
-
loopState.completionPromise = fm.completionPromise;
|
|
275
|
-
loopState.guardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
|
|
276
|
-
|
|
277
|
-
const outputs = await runCommands(fm.commands, pi);
|
|
278
|
-
let body = resolvePlaceholders(rawBody, outputs, { iteration: i, name });
|
|
279
|
-
body = body.replace(/<!--[\s\S]*?-->/g, "");
|
|
280
|
-
const prompt = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n${body}`;
|
|
281
|
-
|
|
282
|
-
const prevPersisted = readPersistedLoopState(ctx);
|
|
283
|
-
if (prevPersisted?.active && prevPersisted.sessionFile === ctx.sessionManager.getSessionFile()) persistLoopState(pi, { ...prevPersisted, active: false });
|
|
284
|
-
ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
|
|
285
|
-
const prevSessionFile = loopState.loopSessionFile;
|
|
286
|
-
const { cancelled } = await ctx.newSession();
|
|
287
|
-
if (cancelled) {
|
|
288
|
-
ctx.ui.notify("Session switch cancelled, stopping loop", "warning");
|
|
289
|
-
break;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
loopState.loopSessionFile = ctx.sessionManager.getSessionFile();
|
|
293
|
-
if (prevSessionFile && prevSessionFile !== loopState.loopSessionFile) failCounts.delete(prevSessionFile);
|
|
294
|
-
if (loopState.loopSessionFile) failCounts.set(loopState.loopSessionFile, 0);
|
|
295
|
-
persistLoopState(pi, {
|
|
296
|
-
active: true,
|
|
297
|
-
sessionFile: loopState.loopSessionFile,
|
|
298
|
-
iteration: loopState.iteration,
|
|
299
|
-
maxIterations: loopState.maxIterations,
|
|
300
|
-
iterationSummaries: loopState.iterationSummaries,
|
|
301
|
-
guardrails: { blockCommands: loopState.guardrails.blockCommands, protectedFiles: loopState.guardrails.protectedFiles },
|
|
302
|
-
stopRequested: false,
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
pi.sendUserMessage(prompt);
|
|
306
|
-
const timeoutMs = fm.timeout * 1000;
|
|
307
|
-
let timedOut = false;
|
|
308
|
-
let idleError: Error | undefined;
|
|
309
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
310
|
-
try {
|
|
311
|
-
await Promise.race([
|
|
312
|
-
ctx.waitForIdle().catch((e: any) => {
|
|
313
|
-
idleError = e instanceof Error ? e : new Error(String(e));
|
|
314
|
-
throw e;
|
|
315
|
-
}),
|
|
316
|
-
new Promise<never>((_, reject) => {
|
|
317
|
-
timer = setTimeout(() => {
|
|
318
|
-
timedOut = true;
|
|
319
|
-
reject(new Error("timeout"));
|
|
320
|
-
}, timeoutMs);
|
|
321
|
-
}),
|
|
322
|
-
]);
|
|
323
|
-
} catch {
|
|
324
|
-
// timedOut is set by timer; idleError means waitForIdle failed
|
|
325
|
-
}
|
|
326
|
-
if (timer) clearTimeout(timer);
|
|
327
|
-
if (timedOut) {
|
|
328
|
-
ctx.ui.notify(`Iteration ${i} timed out after ${fm.timeout}s, stopping loop`, "warning");
|
|
329
|
-
break;
|
|
330
|
-
}
|
|
331
|
-
if (idleError) {
|
|
332
|
-
ctx.ui.notify(`Iteration ${i} agent error: ${idleError.message}, stopping loop`, "error");
|
|
333
|
-
break;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const elapsed = Math.round((Date.now() - iterStart) / 1000);
|
|
337
|
-
loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
|
|
338
|
-
pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
|
|
339
|
-
|
|
340
|
-
const persistedAfter = readPersistedLoopState(ctx);
|
|
341
|
-
if (persistedAfter?.active && persistedAfter.stopRequested) {
|
|
342
|
-
loopState.stopRequested = true;
|
|
343
|
-
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
344
|
-
break;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (fm.completionPromise) {
|
|
348
|
-
const entries = ctx.sessionManager.getEntries();
|
|
349
|
-
for (const entry of entries) {
|
|
350
|
-
if (entry.type === "message" && entry.message?.role === "assistant") {
|
|
351
|
-
const text = entry.message.content?.filter((b: any) => b.type === "text")?.map((b: any) => b.text)?.join("") ?? "";
|
|
352
|
-
const match = text.match(/<promise>([^<]+)<\/promise>/);
|
|
353
|
-
if (match && fm.completionPromise && match[1].trim() === fm.completionPromise.trim()) {
|
|
354
|
-
ctx.ui.notify(`Completion promise matched on iteration ${i}`, "info");
|
|
355
|
-
break iterationLoop;
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
|
|
362
|
-
}
|
|
555
|
+
const ralphPath = await handleDraftCommand("ralph", args ?? "", ctx);
|
|
556
|
+
if (!ralphPath) return;
|
|
557
|
+
await startRalphLoop(ralphPath, ctx);
|
|
558
|
+
},
|
|
559
|
+
});
|
|
363
560
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
|
|
369
|
-
} finally {
|
|
370
|
-
failCounts.clear();
|
|
371
|
-
loopState.active = false;
|
|
372
|
-
loopState.stopRequested = false;
|
|
373
|
-
loopState.loopSessionFile = undefined;
|
|
374
|
-
ctx.ui.setStatus("ralph", undefined);
|
|
375
|
-
persistLoopState(pi, { active: false });
|
|
376
|
-
}
|
|
561
|
+
pi.registerCommand("ralph-draft", {
|
|
562
|
+
description: "Draft a Ralph task without starting it",
|
|
563
|
+
handler: async (args: string, ctx: any) => {
|
|
564
|
+
await handleDraftCommand("ralph-draft", args ?? "", ctx);
|
|
377
565
|
},
|
|
378
566
|
});
|
|
379
567
|
|