@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/src/index.ts CHANGED
@@ -1,19 +1,29 @@
1
- import { parse as parseYaml } from "yaml";
2
1
  import { minimatch } from "minimatch";
3
- import { readFileSync, existsSync } from "node:fs";
4
- import { resolve, join, dirname, basename } from "node:path";
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 defaultFrontmatter(): Frontmatter {
40
- return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
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
- if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
73
- ctx.ui.notify("Invalid max_iterations: must be a positive finite integer", "error");
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 resolveRalphPath(args: string, cwd: string): string {
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(result.killed
125
- ? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
126
- : { name: cmd.name, output: (result.stdout + result.stderr).trim() });
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 { active: false, ralphPath: "", iteration: 0, maxIterations: 50, timeout: 300, completionPromise: undefined, stopRequested: false, iterationSummaries: [], guardrails: { blockCommands: [], protectedFiles: [] }, loopSessionFile: undefined };
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
- for (const pattern of persisted.guardrails?.blockCommands ?? []) {
172
- try {
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 (!/FAIL|ERROR|error:|failed/i.test(output)) return;
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 an autonomous ralph loop from a RALPH.md file",
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
- let name: string;
231
- try {
232
- const ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
233
- const { frontmatter } = parseRalphMd(ralphPath);
234
- if (!validateFrontmatter(frontmatter, ctx)) return;
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
- const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
365
- ctx.ui.notify(`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`, "info");
366
- } catch (err) {
367
- const message = err instanceof Error ? err.message : String(err);
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