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