@lnilluv/pi-ralph-loop 0.3.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/release.yml +8 -39
- package/README.md +53 -160
- package/package.json +2 -2
- package/scripts/version-helper.ts +210 -0
- package/src/index.ts +1388 -187
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +297 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +924 -102
- package/src/runner-rpc.ts +466 -0
- package/src/runner-state.ts +839 -0
- package/src/runner.ts +1042 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +0 -3
- package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/migrate/RALPH.md +27 -0
- package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
- package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
- package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
- package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
- package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
- package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/research/RALPH.md +45 -0
- package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
- package/tests/fixtures/parity/research/expected-outputs.md +22 -0
- package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
- package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
- package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
- package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
- package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
- package/tests/fixtures/parity/research/source-manifest.md +20 -0
- package/tests/index.test.ts +3801 -0
- package/tests/parity/README.md +9 -0
- package/tests/parity/harness.py +526 -0
- package/tests/parity-harness.test.ts +42 -0
- package/tests/parity-research-fixture.test.ts +34 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +434 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +1413 -19
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +446 -0
- package/tests/runner-state.test.ts +581 -0
- package/tests/runner.test.ts +1552 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tests/version-helper.test.ts +75 -0
package/src/index.ts
CHANGED
|
@@ -1,56 +1,249 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { basename, dirname, join, relative } from "node:path";
|
|
4
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext, SessionEntry, AgentEndEvent as PiAgentEndEvent, ToolResultEvent as PiToolResultEvent } from "@mariozechner/pi-coding-agent";
|
|
5
5
|
import {
|
|
6
6
|
buildMissionBrief,
|
|
7
|
-
classifyIdleState,
|
|
8
|
-
generateDraft,
|
|
9
7
|
inspectExistingTarget,
|
|
10
|
-
inspectRepo,
|
|
11
8
|
parseCommandArgs,
|
|
12
9
|
parseRalphMarkdown,
|
|
13
10
|
planTaskDraftTarget,
|
|
14
11
|
renderIterationPrompt,
|
|
15
12
|
renderRalphBody,
|
|
16
|
-
|
|
13
|
+
resolveCommandRun,
|
|
14
|
+
replaceArgsPlaceholders,
|
|
15
|
+
runtimeArgEntriesToMap,
|
|
17
16
|
shouldStopForCompletionPromise,
|
|
18
17
|
shouldWarnForBashFailure,
|
|
19
18
|
shouldValidateExistingDraft,
|
|
20
19
|
validateDraftContent,
|
|
21
20
|
validateFrontmatter as validateFrontmatterMessage,
|
|
21
|
+
validateRuntimeArgs,
|
|
22
22
|
createSiblingTarget,
|
|
23
23
|
findBlockedCommandPattern,
|
|
24
24
|
} from "./ralph.ts";
|
|
25
|
-
import
|
|
25
|
+
import { matchesProtectedPath } from "./secret-paths.ts";
|
|
26
|
+
import type { CommandDef, CommandOutput, DraftPlan, DraftTarget, Frontmatter, RuntimeArgs } from "./ralph.ts";
|
|
27
|
+
import { createDraftPlan as createDraftPlanService } from "./ralph-draft.ts";
|
|
28
|
+
import type { StrengthenDraftRuntime } from "./ralph-draft-llm.ts";
|
|
29
|
+
import { runRalphLoop } from "./runner.ts";
|
|
30
|
+
import {
|
|
31
|
+
checkStopSignal,
|
|
32
|
+
createStopSignal,
|
|
33
|
+
createCancelSignal,
|
|
34
|
+
checkCancelSignal,
|
|
35
|
+
listActiveLoopRegistryEntries,
|
|
36
|
+
readActiveLoopRegistry,
|
|
37
|
+
readIterationRecords,
|
|
38
|
+
readStatusFile,
|
|
39
|
+
recordActiveLoopStopRequest,
|
|
40
|
+
writeActiveLoopRegistryEntry,
|
|
41
|
+
type ActiveLoopRegistryEntry,
|
|
42
|
+
} from "./runner-state.ts";
|
|
43
|
+
|
|
44
|
+
type ProgressState = boolean | "unknown";
|
|
45
|
+
|
|
46
|
+
type IterationSummary = {
|
|
47
|
+
iteration: number;
|
|
48
|
+
duration: number;
|
|
49
|
+
progress: ProgressState;
|
|
50
|
+
changedFiles: string[];
|
|
51
|
+
noProgressStreak: number;
|
|
52
|
+
snapshotTruncated?: boolean;
|
|
53
|
+
snapshotErrorCount?: number;
|
|
54
|
+
};
|
|
26
55
|
|
|
27
56
|
type LoopState = {
|
|
28
57
|
active: boolean;
|
|
29
58
|
ralphPath: string;
|
|
59
|
+
taskDir: string;
|
|
60
|
+
cwd: string;
|
|
30
61
|
iteration: number;
|
|
31
62
|
maxIterations: number;
|
|
32
63
|
timeout: number;
|
|
33
64
|
completionPromise?: string;
|
|
34
65
|
stopRequested: boolean;
|
|
35
|
-
|
|
66
|
+
noProgressStreak: number;
|
|
67
|
+
iterationSummaries: IterationSummary[];
|
|
36
68
|
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
37
|
-
|
|
69
|
+
observedTaskDirWrites: Set<string>;
|
|
70
|
+
loopToken?: string;
|
|
38
71
|
};
|
|
39
72
|
type PersistedLoopState = {
|
|
40
73
|
active: boolean;
|
|
41
|
-
|
|
74
|
+
loopToken?: string;
|
|
75
|
+
cwd?: string;
|
|
76
|
+
taskDir?: string;
|
|
42
77
|
iteration?: number;
|
|
43
78
|
maxIterations?: number;
|
|
44
|
-
|
|
79
|
+
noProgressStreak?: number;
|
|
80
|
+
iterationSummaries?: IterationSummary[];
|
|
45
81
|
guardrails?: { blockCommands: string[]; protectedFiles: string[] };
|
|
46
82
|
stopRequested?: boolean;
|
|
47
83
|
};
|
|
48
84
|
|
|
49
|
-
|
|
50
|
-
|
|
85
|
+
type ActiveLoopState = PersistedLoopState & { active: true; loopToken: string; envMalformed?: boolean };
|
|
86
|
+
type ActiveIterationState = ActiveLoopState & { iteration: number };
|
|
87
|
+
|
|
88
|
+
const RALPH_RUNNER_TASK_DIR_ENV = "RALPH_RUNNER_TASK_DIR";
|
|
89
|
+
const RALPH_RUNNER_CWD_ENV = "RALPH_RUNNER_CWD";
|
|
90
|
+
const RALPH_RUNNER_LOOP_TOKEN_ENV = "RALPH_RUNNER_LOOP_TOKEN";
|
|
91
|
+
const RALPH_RUNNER_CURRENT_ITERATION_ENV = "RALPH_RUNNER_CURRENT_ITERATION";
|
|
92
|
+
const RALPH_RUNNER_MAX_ITERATIONS_ENV = "RALPH_RUNNER_MAX_ITERATIONS";
|
|
93
|
+
const RALPH_RUNNER_NO_PROGRESS_STREAK_ENV = "RALPH_RUNNER_NO_PROGRESS_STREAK";
|
|
94
|
+
const RALPH_RUNNER_GUARDRAILS_ENV = "RALPH_RUNNER_GUARDRAILS";
|
|
95
|
+
|
|
96
|
+
type CommandContext = ExtensionCommandContext;
|
|
97
|
+
type CommandSessionEntry = SessionEntry;
|
|
98
|
+
|
|
99
|
+
type DraftPlanFactory = (
|
|
100
|
+
task: string,
|
|
101
|
+
target: DraftTarget,
|
|
102
|
+
cwd: string,
|
|
103
|
+
runtime?: StrengthenDraftRuntime,
|
|
104
|
+
) => Promise<DraftPlan>;
|
|
105
|
+
|
|
106
|
+
type RegisterRalphCommandServices = {
|
|
107
|
+
createDraftPlan?: DraftPlanFactory;
|
|
108
|
+
runRalphLoopFn?: typeof runRalphLoop;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
type StopTargetSource = "session" | "registry" | "status";
|
|
112
|
+
|
|
113
|
+
type StopTarget = {
|
|
114
|
+
cwd: string;
|
|
115
|
+
taskDir: string;
|
|
116
|
+
ralphPath: string;
|
|
117
|
+
loopToken: string;
|
|
118
|
+
currentIteration: number;
|
|
119
|
+
maxIterations: number;
|
|
120
|
+
startedAt: string;
|
|
121
|
+
source: StopTargetSource;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
type ResolveRalphTargetResult =
|
|
125
|
+
| { kind: "resolved"; taskDir: string }
|
|
126
|
+
| { kind: "not-found" };
|
|
127
|
+
|
|
128
|
+
function resolveRalphTarget(
|
|
129
|
+
ctx: Pick<CommandContext, "cwd" | "sessionManager" | "ui">,
|
|
130
|
+
options: {
|
|
131
|
+
commandName: string;
|
|
132
|
+
explicitPath?: string;
|
|
133
|
+
checkCrossProcess?: boolean;
|
|
134
|
+
allowCompletedRuns?: boolean;
|
|
135
|
+
},
|
|
136
|
+
): ResolveRalphTargetResult | undefined {
|
|
137
|
+
const { commandName, explicitPath, checkCrossProcess = false, allowCompletedRuns = false } = options;
|
|
138
|
+
const now = new Date().toISOString();
|
|
139
|
+
const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
|
|
140
|
+
const { target: sessionTarget } = resolveSessionStopTarget(ctx, now);
|
|
141
|
+
const resolvedExplicitPath = explicitPath?.trim();
|
|
142
|
+
|
|
143
|
+
if (resolvedExplicitPath) {
|
|
144
|
+
const inspection = inspectExistingTarget(resolvedExplicitPath, ctx.cwd, true);
|
|
145
|
+
if (inspection.kind === "run") {
|
|
146
|
+
const taskDir = dirname(inspection.ralphPath);
|
|
147
|
+
if (checkCrossProcess) {
|
|
148
|
+
const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir);
|
|
149
|
+
if (registryTarget) {
|
|
150
|
+
return { kind: "resolved", taskDir: registryTarget.taskDir };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const statusFile = readStatusFile(taskDir);
|
|
154
|
+
if (
|
|
155
|
+
statusFile &&
|
|
156
|
+
(statusFile.status === "running" || statusFile.status === "initializing") &&
|
|
157
|
+
typeof statusFile.cwd === "string" &&
|
|
158
|
+
statusFile.cwd.length > 0
|
|
159
|
+
) {
|
|
160
|
+
const statusRegistryTarget = listActiveLoopRegistryEntries(statusFile.cwd).find(
|
|
161
|
+
(entry) => entry.taskDir === taskDir && entry.loopToken === statusFile.loopToken,
|
|
162
|
+
);
|
|
163
|
+
if (statusRegistryTarget) {
|
|
164
|
+
return { kind: "resolved", taskDir: statusRegistryTarget.taskDir };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { kind: "resolved", taskDir };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (allowCompletedRuns) {
|
|
173
|
+
const taskDir = resolve(ctx.cwd, resolvedExplicitPath);
|
|
174
|
+
if (existsSync(join(taskDir, ".ralph-runner"))) {
|
|
175
|
+
return { kind: "resolved", taskDir };
|
|
176
|
+
}
|
|
177
|
+
ctx.ui.notify(`No ralph run data found at ${displayPath(ctx.cwd, taskDir)}.`, "error");
|
|
178
|
+
return { kind: "not-found" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (inspection.kind === "invalid-markdown") {
|
|
182
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is not stoppable.`, "error");
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
if (inspection.kind === "invalid-target") {
|
|
186
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
if (inspection.kind === "dir-without-ralph" || inspection.kind === "missing-path") {
|
|
190
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.dirPath)}.`, "warning");
|
|
191
|
+
return { kind: "not-found" };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
ctx.ui.notify(`${commandName} expects a task folder or RALPH.md path.`, "error");
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (sessionTarget) {
|
|
199
|
+
return { kind: "resolved", taskDir: sessionTarget.taskDir };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const activeEntries = activeRegistryEntries();
|
|
203
|
+
if (activeEntries.length === 0) {
|
|
204
|
+
ctx.ui.notify(
|
|
205
|
+
allowCompletedRuns
|
|
206
|
+
? `No ralph run data found. Specify a task path with ${commandName} <path>.`
|
|
207
|
+
: "No active ralph loops found.",
|
|
208
|
+
"warning",
|
|
209
|
+
);
|
|
210
|
+
return { kind: "not-found" };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (activeEntries.length > 1) {
|
|
214
|
+
ctx.ui.notify(
|
|
215
|
+
`Multiple active ralph loops found. Use ${commandName} <task folder or RALPH.md> for an explicit target path.`,
|
|
216
|
+
"error",
|
|
217
|
+
);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return { kind: "resolved", taskDir: activeEntries[0].taskDir };
|
|
51
222
|
}
|
|
52
223
|
|
|
53
|
-
|
|
224
|
+
type ToolEvent = {
|
|
225
|
+
toolName?: string;
|
|
226
|
+
toolCallId?: string;
|
|
227
|
+
input?: {
|
|
228
|
+
path?: string;
|
|
229
|
+
command?: string;
|
|
230
|
+
};
|
|
231
|
+
isError?: boolean;
|
|
232
|
+
success?: boolean;
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
type AgentEndEvent = PiAgentEndEvent;
|
|
236
|
+
|
|
237
|
+
type ToolResultEvent = PiToolResultEvent;
|
|
238
|
+
|
|
239
|
+
type BeforeAgentStartEvent = {
|
|
240
|
+
systemPrompt: string;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
type EventContext = Pick<CommandContext, "sessionManager">;
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
function validateFrontmatter(fm: Frontmatter, ctx: Pick<CommandContext, "ui">): boolean {
|
|
54
247
|
const error = validateFrontmatterMessage(fm);
|
|
55
248
|
if (error) {
|
|
56
249
|
ctx.ui.notify(error, "error");
|
|
@@ -59,17 +252,30 @@ function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
|
|
|
59
252
|
return true;
|
|
60
253
|
}
|
|
61
254
|
|
|
62
|
-
export async function runCommands(
|
|
255
|
+
export async function runCommands(
|
|
256
|
+
commands: CommandDef[],
|
|
257
|
+
blockPatterns: string[],
|
|
258
|
+
pi: ExtensionAPI,
|
|
259
|
+
runtimeArgs: RuntimeArgs = {},
|
|
260
|
+
cwd?: string,
|
|
261
|
+
taskDir?: string,
|
|
262
|
+
): Promise<CommandOutput[]> {
|
|
263
|
+
const repoCwd = cwd ?? process.cwd();
|
|
63
264
|
const results: CommandOutput[] = [];
|
|
64
265
|
for (const cmd of commands) {
|
|
65
|
-
const
|
|
266
|
+
const semanticRun = replaceArgsPlaceholders(cmd.run, runtimeArgs);
|
|
267
|
+
const blockedPattern = findBlockedCommandPattern(semanticRun, blockPatterns);
|
|
268
|
+
const resolvedRun = resolveCommandRun(cmd.run, runtimeArgs);
|
|
66
269
|
if (blockedPattern) {
|
|
270
|
+
pi.appendEntry?.("ralph-blocked-command", { name: cmd.name, command: semanticRun, blockedPattern, cwd: repoCwd, taskDir });
|
|
67
271
|
results.push({ name: cmd.name, output: `[blocked by guardrail: ${blockedPattern}]` });
|
|
68
272
|
continue;
|
|
69
273
|
}
|
|
70
274
|
|
|
275
|
+
const commandCwd = semanticRun.trim().startsWith("./") ? taskDir ?? repoCwd : repoCwd;
|
|
276
|
+
|
|
71
277
|
try {
|
|
72
|
-
const result = await pi.exec("bash", ["-c",
|
|
278
|
+
const result = await pi.exec("bash", ["-c", resolvedRun], { timeout: cmd.timeout * 1000, cwd: commandCwd });
|
|
73
279
|
results.push(
|
|
74
280
|
result.killed
|
|
75
281
|
? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
|
|
@@ -83,22 +289,99 @@ export async function runCommands(commands: CommandDef[], blockPatterns: string[
|
|
|
83
289
|
return results;
|
|
84
290
|
}
|
|
85
291
|
|
|
292
|
+
const SNAPSHOT_IGNORED_DIR_NAMES = new Set([
|
|
293
|
+
".git",
|
|
294
|
+
"node_modules",
|
|
295
|
+
".next",
|
|
296
|
+
".turbo",
|
|
297
|
+
".cache",
|
|
298
|
+
"coverage",
|
|
299
|
+
"dist",
|
|
300
|
+
"build",
|
|
301
|
+
".ralph-runner",
|
|
302
|
+
]);
|
|
303
|
+
const SNAPSHOT_MAX_FILES = 200;
|
|
304
|
+
const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
|
|
305
|
+
const SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS = 20;
|
|
306
|
+
const SNAPSHOT_POST_IDLE_POLL_WINDOW_MS = 100;
|
|
307
|
+
const RALPH_PROGRESS_FILE = "RALPH_PROGRESS.md";
|
|
308
|
+
|
|
309
|
+
type WorkspaceSnapshot = {
|
|
310
|
+
files: Map<string, string>;
|
|
311
|
+
truncated: boolean;
|
|
312
|
+
errorCount: number;
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
type ProgressAssessment = {
|
|
316
|
+
progress: ProgressState;
|
|
317
|
+
changedFiles: string[];
|
|
318
|
+
snapshotTruncated: boolean;
|
|
319
|
+
snapshotErrorCount: number;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
type IterationCompletion = {
|
|
323
|
+
messages: PiAgentEndEvent["messages"];
|
|
324
|
+
observedTaskDirWrites: Set<string>;
|
|
325
|
+
error?: Error;
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
type Deferred<T> = {
|
|
329
|
+
promise: Promise<T>;
|
|
330
|
+
resolve(value: T): void;
|
|
331
|
+
reject(reason?: unknown): void;
|
|
332
|
+
settled: boolean;
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
type PendingIterationState = {
|
|
336
|
+
prompt: string;
|
|
337
|
+
completion: Deferred<IterationCompletion>;
|
|
338
|
+
toolCallPaths: Map<string, string>;
|
|
339
|
+
observedTaskDirWrites: Set<string>;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
function createDeferred<T>(): Deferred<T> {
|
|
343
|
+
let resolvePromise!: (value: T) => void;
|
|
344
|
+
let rejectPromise!: (reason?: unknown) => void;
|
|
345
|
+
const deferred: Deferred<T> = {
|
|
346
|
+
promise: new Promise<T>((resolve, reject) => {
|
|
347
|
+
resolvePromise = resolve;
|
|
348
|
+
rejectPromise = reject;
|
|
349
|
+
}),
|
|
350
|
+
resolve(value: T) {
|
|
351
|
+
if (deferred.settled) return;
|
|
352
|
+
deferred.settled = true;
|
|
353
|
+
resolvePromise(value);
|
|
354
|
+
},
|
|
355
|
+
reject(reason?: unknown) {
|
|
356
|
+
if (deferred.settled) return;
|
|
357
|
+
deferred.settled = true;
|
|
358
|
+
rejectPromise(reason);
|
|
359
|
+
},
|
|
360
|
+
settled: false,
|
|
361
|
+
};
|
|
362
|
+
return deferred;
|
|
363
|
+
}
|
|
364
|
+
|
|
86
365
|
function defaultLoopState(): LoopState {
|
|
87
366
|
return {
|
|
88
367
|
active: false,
|
|
89
368
|
ralphPath: "",
|
|
369
|
+
taskDir: "",
|
|
90
370
|
iteration: 0,
|
|
91
371
|
maxIterations: 50,
|
|
92
372
|
timeout: 300,
|
|
93
373
|
completionPromise: undefined,
|
|
94
374
|
stopRequested: false,
|
|
375
|
+
noProgressStreak: 0,
|
|
95
376
|
iterationSummaries: [],
|
|
96
377
|
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
97
|
-
|
|
378
|
+
observedTaskDirWrites: new Set(),
|
|
379
|
+
loopToken: undefined,
|
|
380
|
+
cwd: "",
|
|
98
381
|
};
|
|
99
382
|
}
|
|
100
383
|
|
|
101
|
-
function readPersistedLoopState(ctx:
|
|
384
|
+
function readPersistedLoopState(ctx: Pick<CommandContext, "sessionManager">): PersistedLoopState | undefined {
|
|
102
385
|
const entries = ctx.sessionManager.getEntries();
|
|
103
386
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
104
387
|
const entry = entries[i];
|
|
@@ -113,6 +396,419 @@ function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
|
|
|
113
396
|
pi.appendEntry("ralph-loop-state", data);
|
|
114
397
|
}
|
|
115
398
|
|
|
399
|
+
function toPersistedLoopState(state: LoopState, overrides: Partial<PersistedLoopState> = {}): PersistedLoopState {
|
|
400
|
+
return {
|
|
401
|
+
active: state.active,
|
|
402
|
+
loopToken: state.loopToken,
|
|
403
|
+
cwd: state.cwd,
|
|
404
|
+
taskDir: state.taskDir,
|
|
405
|
+
iteration: state.iteration,
|
|
406
|
+
maxIterations: state.maxIterations,
|
|
407
|
+
noProgressStreak: state.noProgressStreak,
|
|
408
|
+
iterationSummaries: state.iterationSummaries,
|
|
409
|
+
guardrails: { blockCommands: state.guardrails.blockCommands, protectedFiles: state.guardrails.protectedFiles },
|
|
410
|
+
stopRequested: state.stopRequested,
|
|
411
|
+
...overrides,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function readActiveLoopState(ctx: Pick<CommandContext, "sessionManager">): ActiveLoopState | undefined {
|
|
416
|
+
const state = readPersistedLoopState(ctx);
|
|
417
|
+
if (state?.active !== true) return undefined;
|
|
418
|
+
if (typeof state.loopToken !== "string" || state.loopToken.length === 0) return undefined;
|
|
419
|
+
return state as ActiveLoopState;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function sanitizeStringArray(value: unknown): string[] {
|
|
423
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function sanitizeGuardrails(value: unknown): { blockCommands: string[]; protectedFiles: string[] } {
|
|
427
|
+
if (!value || typeof value !== "object") {
|
|
428
|
+
return { blockCommands: [], protectedFiles: [] };
|
|
429
|
+
}
|
|
430
|
+
const guardrails = value as { blockCommands?: unknown; protectedFiles?: unknown };
|
|
431
|
+
return {
|
|
432
|
+
blockCommands: sanitizeStringArray(guardrails.blockCommands),
|
|
433
|
+
protectedFiles: sanitizeStringArray(guardrails.protectedFiles),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function sanitizeProgressState(value: unknown): ProgressState {
|
|
438
|
+
return value === true || value === false || value === "unknown" ? value : "unknown";
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function sanitizeIterationSummary(record: unknown, loopToken: string): IterationSummary | undefined {
|
|
442
|
+
if (!record || typeof record !== "object") return undefined;
|
|
443
|
+
const iterationRecord = record as {
|
|
444
|
+
loopToken?: unknown;
|
|
445
|
+
iteration?: unknown;
|
|
446
|
+
durationMs?: unknown;
|
|
447
|
+
progress?: unknown;
|
|
448
|
+
changedFiles?: unknown;
|
|
449
|
+
noProgressStreak?: unknown;
|
|
450
|
+
snapshotTruncated?: unknown;
|
|
451
|
+
snapshotErrorCount?: unknown;
|
|
452
|
+
};
|
|
453
|
+
if (iterationRecord.loopToken !== loopToken) return undefined;
|
|
454
|
+
if (typeof iterationRecord.iteration !== "number" || !Number.isFinite(iterationRecord.iteration)) return undefined;
|
|
455
|
+
|
|
456
|
+
const durationMs = typeof iterationRecord.durationMs === "number" && Number.isFinite(iterationRecord.durationMs)
|
|
457
|
+
? iterationRecord.durationMs
|
|
458
|
+
: 0;
|
|
459
|
+
const noProgressStreak = typeof iterationRecord.noProgressStreak === "number" && Number.isFinite(iterationRecord.noProgressStreak)
|
|
460
|
+
? iterationRecord.noProgressStreak
|
|
461
|
+
: 0;
|
|
462
|
+
const snapshotErrorCount = typeof iterationRecord.snapshotErrorCount === "number" && Number.isFinite(iterationRecord.snapshotErrorCount)
|
|
463
|
+
? iterationRecord.snapshotErrorCount
|
|
464
|
+
: undefined;
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
iteration: iterationRecord.iteration,
|
|
468
|
+
duration: Math.round(durationMs / 1000),
|
|
469
|
+
progress: sanitizeProgressState(iterationRecord.progress),
|
|
470
|
+
changedFiles: sanitizeStringArray(iterationRecord.changedFiles),
|
|
471
|
+
noProgressStreak,
|
|
472
|
+
snapshotTruncated: typeof iterationRecord.snapshotTruncated === "boolean" ? iterationRecord.snapshotTruncated : undefined,
|
|
473
|
+
snapshotErrorCount,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function parseLoopContractInteger(raw: string | undefined): number | undefined {
|
|
478
|
+
if (typeof raw !== "string") return undefined;
|
|
479
|
+
const trimmed = raw.trim();
|
|
480
|
+
if (!/^-?\d+$/.test(trimmed)) return undefined;
|
|
481
|
+
const parsed = Number(trimmed);
|
|
482
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseLoopContractGuardrails(raw: string | undefined): { blockCommands: string[]; protectedFiles: string[] } | undefined {
|
|
486
|
+
if (typeof raw !== "string") return undefined;
|
|
487
|
+
try {
|
|
488
|
+
const parsed: unknown = JSON.parse(raw);
|
|
489
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
490
|
+
const guardrails = parsed as { blockCommands?: unknown; protectedFiles?: unknown };
|
|
491
|
+
if (
|
|
492
|
+
!Array.isArray(guardrails.blockCommands) ||
|
|
493
|
+
!guardrails.blockCommands.every((item) => typeof item === "string") ||
|
|
494
|
+
!Array.isArray(guardrails.protectedFiles) ||
|
|
495
|
+
!guardrails.protectedFiles.every((item) => typeof item === "string")
|
|
496
|
+
) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
blockCommands: [...guardrails.blockCommands],
|
|
501
|
+
protectedFiles: [...guardrails.protectedFiles],
|
|
502
|
+
};
|
|
503
|
+
} catch {
|
|
504
|
+
return undefined;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function isStringArray(value: unknown): value is string[] {
|
|
509
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
|
513
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function createFailClosedLoopState(taskDir: string, cwd?: string): ActiveLoopState {
|
|
517
|
+
return {
|
|
518
|
+
active: true,
|
|
519
|
+
loopToken: "",
|
|
520
|
+
cwd: cwd && cwd.length > 0 ? cwd : taskDir,
|
|
521
|
+
taskDir,
|
|
522
|
+
iteration: 0,
|
|
523
|
+
maxIterations: 0,
|
|
524
|
+
noProgressStreak: 0,
|
|
525
|
+
iterationSummaries: [],
|
|
526
|
+
guardrails: { blockCommands: [".*"], protectedFiles: ["**/*"] },
|
|
527
|
+
stopRequested: checkStopSignal(taskDir),
|
|
528
|
+
envMalformed: true,
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function readEnvLoopState(taskDir: string): ActiveLoopState | undefined {
|
|
533
|
+
const cwd = process.env[RALPH_RUNNER_CWD_ENV]?.trim();
|
|
534
|
+
const loopToken = process.env[RALPH_RUNNER_LOOP_TOKEN_ENV]?.trim();
|
|
535
|
+
const currentIteration = parseLoopContractInteger(process.env[RALPH_RUNNER_CURRENT_ITERATION_ENV]);
|
|
536
|
+
const maxIterations = parseLoopContractInteger(process.env[RALPH_RUNNER_MAX_ITERATIONS_ENV]);
|
|
537
|
+
const noProgressStreak = parseLoopContractInteger(process.env[RALPH_RUNNER_NO_PROGRESS_STREAK_ENV]);
|
|
538
|
+
const guardrails = parseLoopContractGuardrails(process.env[RALPH_RUNNER_GUARDRAILS_ENV]);
|
|
539
|
+
|
|
540
|
+
if (
|
|
541
|
+
!cwd ||
|
|
542
|
+
!loopToken ||
|
|
543
|
+
currentIteration === undefined ||
|
|
544
|
+
currentIteration < 0 ||
|
|
545
|
+
maxIterations === undefined ||
|
|
546
|
+
maxIterations <= 0 ||
|
|
547
|
+
noProgressStreak === undefined ||
|
|
548
|
+
noProgressStreak < 0 ||
|
|
549
|
+
!guardrails
|
|
550
|
+
) {
|
|
551
|
+
return undefined;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const iterationSummaries = readIterationRecords(taskDir)
|
|
555
|
+
.map((record) => sanitizeIterationSummary(record, loopToken))
|
|
556
|
+
.filter((summary): summary is IterationSummary => summary !== undefined);
|
|
557
|
+
|
|
558
|
+
return {
|
|
559
|
+
active: true,
|
|
560
|
+
loopToken,
|
|
561
|
+
cwd,
|
|
562
|
+
taskDir,
|
|
563
|
+
iteration: currentIteration,
|
|
564
|
+
maxIterations,
|
|
565
|
+
noProgressStreak,
|
|
566
|
+
iterationSummaries,
|
|
567
|
+
guardrails,
|
|
568
|
+
stopRequested: checkStopSignal(taskDir),
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function readDurableLoopState(taskDir: string, envState: ActiveLoopState): ActiveLoopState | undefined {
|
|
573
|
+
const envGuardrails = envState.guardrails;
|
|
574
|
+
if (!envGuardrails) return undefined;
|
|
575
|
+
|
|
576
|
+
const durableStatus = readStatusFile(taskDir);
|
|
577
|
+
if (!durableStatus || typeof durableStatus !== "object") return undefined;
|
|
578
|
+
|
|
579
|
+
const status = durableStatus as Record<string, unknown>;
|
|
580
|
+
const guardrails = status.guardrails as Record<string, unknown> | undefined;
|
|
581
|
+
if (
|
|
582
|
+
typeof status.loopToken !== "string" ||
|
|
583
|
+
status.loopToken.length === 0 ||
|
|
584
|
+
typeof status.cwd !== "string" ||
|
|
585
|
+
status.cwd.length === 0 ||
|
|
586
|
+
typeof status.currentIteration !== "number" ||
|
|
587
|
+
!Number.isInteger(status.currentIteration) ||
|
|
588
|
+
status.currentIteration < 0 ||
|
|
589
|
+
typeof status.maxIterations !== "number" ||
|
|
590
|
+
!Number.isInteger(status.maxIterations) ||
|
|
591
|
+
status.maxIterations <= 0 ||
|
|
592
|
+
typeof status.taskDir !== "string" ||
|
|
593
|
+
status.taskDir !== taskDir ||
|
|
594
|
+
!guardrails ||
|
|
595
|
+
!isStringArray(guardrails.blockCommands) ||
|
|
596
|
+
!isStringArray(guardrails.protectedFiles)
|
|
597
|
+
) {
|
|
598
|
+
return undefined;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const durableLoopToken = status.loopToken;
|
|
602
|
+
const durableCwd = status.cwd;
|
|
603
|
+
const durableGuardrails = guardrails as { blockCommands: string[]; protectedFiles: string[] };
|
|
604
|
+
|
|
605
|
+
if (
|
|
606
|
+
durableLoopToken !== envState.loopToken ||
|
|
607
|
+
durableCwd !== envState.cwd ||
|
|
608
|
+
status.currentIteration !== envState.iteration ||
|
|
609
|
+
status.maxIterations !== envState.maxIterations ||
|
|
610
|
+
!areStringArraysEqual(durableGuardrails.blockCommands, envGuardrails.blockCommands) ||
|
|
611
|
+
!areStringArraysEqual(durableGuardrails.protectedFiles, envGuardrails.protectedFiles)
|
|
612
|
+
) {
|
|
613
|
+
return undefined;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const iterationSummaries = readIterationRecords(taskDir)
|
|
617
|
+
.map((record) => sanitizeIterationSummary(record, durableLoopToken))
|
|
618
|
+
.filter((summary): summary is IterationSummary => summary !== undefined);
|
|
619
|
+
|
|
620
|
+
return {
|
|
621
|
+
active: true,
|
|
622
|
+
loopToken: durableLoopToken,
|
|
623
|
+
cwd: durableCwd,
|
|
624
|
+
taskDir,
|
|
625
|
+
iteration: status.currentIteration,
|
|
626
|
+
maxIterations: status.maxIterations,
|
|
627
|
+
noProgressStreak: envState.noProgressStreak,
|
|
628
|
+
iterationSummaries,
|
|
629
|
+
guardrails: {
|
|
630
|
+
blockCommands: [...durableGuardrails.blockCommands],
|
|
631
|
+
protectedFiles: [...durableGuardrails.protectedFiles],
|
|
632
|
+
},
|
|
633
|
+
stopRequested: checkStopSignal(taskDir),
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function resolveActiveLoopState(ctx: Pick<CommandContext, "sessionManager">): ActiveLoopState | undefined {
|
|
638
|
+
const taskDir = process.env[RALPH_RUNNER_TASK_DIR_ENV]?.trim();
|
|
639
|
+
if (taskDir) {
|
|
640
|
+
const envState = readEnvLoopState(taskDir);
|
|
641
|
+
if (!envState) return createFailClosedLoopState(taskDir, process.env[RALPH_RUNNER_CWD_ENV]?.trim() || undefined);
|
|
642
|
+
return readDurableLoopState(taskDir, envState) ?? createFailClosedLoopState(taskDir, envState.cwd);
|
|
643
|
+
}
|
|
644
|
+
return readActiveLoopState(ctx);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function resolveActiveIterationState(ctx: Pick<CommandContext, "sessionManager">): ActiveIterationState | undefined {
|
|
648
|
+
const state = resolveActiveLoopState(ctx);
|
|
649
|
+
if (!state || typeof state.iteration !== "number") return undefined;
|
|
650
|
+
return state as ActiveIterationState;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function getLoopIterationKey(loopToken: string, iteration: number): string {
|
|
654
|
+
return `${loopToken}:${iteration}`;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function normalizeSnapshotPath(filePath: string): string {
|
|
658
|
+
return filePath.split("\\").join("/");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function captureTaskDirectorySnapshot(ralphPath: string): WorkspaceSnapshot {
|
|
662
|
+
const taskDir = dirname(ralphPath);
|
|
663
|
+
const progressMemoryPath = join(taskDir, RALPH_PROGRESS_FILE);
|
|
664
|
+
const files = new Map<string, string>();
|
|
665
|
+
let truncated = false;
|
|
666
|
+
let bytesRead = 0;
|
|
667
|
+
let errorCount = 0;
|
|
668
|
+
|
|
669
|
+
const walk = (dirPath: string) => {
|
|
670
|
+
let entries;
|
|
671
|
+
try {
|
|
672
|
+
entries = readdirSync(dirPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
673
|
+
} catch {
|
|
674
|
+
errorCount += 1;
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
for (const entry of entries) {
|
|
679
|
+
if (truncated) return;
|
|
680
|
+
const fullPath = join(dirPath, entry.name);
|
|
681
|
+
|
|
682
|
+
if (entry.isDirectory()) {
|
|
683
|
+
if (SNAPSHOT_IGNORED_DIR_NAMES.has(entry.name)) continue;
|
|
684
|
+
walk(fullPath);
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
if (!entry.isFile() || fullPath === ralphPath || fullPath === progressMemoryPath) continue;
|
|
688
|
+
if (files.size >= SNAPSHOT_MAX_FILES) {
|
|
689
|
+
truncated = true;
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const relPath = normalizeSnapshotPath(relative(taskDir, fullPath));
|
|
694
|
+
if (!relPath || relPath.startsWith("..")) continue;
|
|
695
|
+
|
|
696
|
+
let content;
|
|
697
|
+
try {
|
|
698
|
+
content = readFileSync(fullPath);
|
|
699
|
+
} catch {
|
|
700
|
+
errorCount += 1;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (bytesRead + content.byteLength > SNAPSHOT_MAX_BYTES) {
|
|
704
|
+
truncated = true;
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
bytesRead += content.byteLength;
|
|
709
|
+
files.set(relPath, `${content.byteLength}:${createHash("sha1").update(content).digest("hex")}`);
|
|
710
|
+
}
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
if (existsSync(taskDir)) walk(taskDir);
|
|
714
|
+
return { files, truncated, errorCount };
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function diffTaskDirectorySnapshots(before: WorkspaceSnapshot, after: WorkspaceSnapshot): string[] {
|
|
718
|
+
const changed = new Set<string>();
|
|
719
|
+
for (const [filePath, fingerprint] of before.files) {
|
|
720
|
+
if (after.files.get(filePath) !== fingerprint) changed.add(filePath);
|
|
721
|
+
}
|
|
722
|
+
for (const filePath of after.files.keys()) {
|
|
723
|
+
if (!before.files.has(filePath)) changed.add(filePath);
|
|
724
|
+
}
|
|
725
|
+
return [...changed].sort((a, b) => a.localeCompare(b));
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function resolveTaskDirObservedPath(taskDir: string, cwd: string, filePath: string): string | undefined {
|
|
729
|
+
if (!taskDir || !cwd || !filePath) return undefined;
|
|
730
|
+
const relPath = normalizeSnapshotPath(relative(resolve(taskDir), resolve(cwd, filePath)));
|
|
731
|
+
if (!relPath || relPath === "." || relPath.startsWith("..")) return undefined;
|
|
732
|
+
return relPath;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function delay(ms: number): Promise<void> {
|
|
736
|
+
return new Promise((resolveDelay) => {
|
|
737
|
+
setTimeout(resolveDelay, ms);
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function assessTaskDirectoryProgress(
|
|
742
|
+
ralphPath: string,
|
|
743
|
+
before: WorkspaceSnapshot,
|
|
744
|
+
observedTaskDirWrites: ReadonlySet<string>,
|
|
745
|
+
): Promise<ProgressAssessment> {
|
|
746
|
+
let after = captureTaskDirectorySnapshot(ralphPath);
|
|
747
|
+
let changedFiles = diffTaskDirectorySnapshots(before, after);
|
|
748
|
+
let snapshotTruncated = before.truncated || after.truncated;
|
|
749
|
+
let snapshotErrorCount = before.errorCount + after.errorCount;
|
|
750
|
+
|
|
751
|
+
if (changedFiles.length > 0) {
|
|
752
|
+
return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
for (let remainingMs = SNAPSHOT_POST_IDLE_POLL_WINDOW_MS; remainingMs > 0; remainingMs -= SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS) {
|
|
756
|
+
await delay(Math.min(SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS, remainingMs));
|
|
757
|
+
after = captureTaskDirectorySnapshot(ralphPath);
|
|
758
|
+
changedFiles = diffTaskDirectorySnapshots(before, after);
|
|
759
|
+
snapshotTruncated ||= after.truncated;
|
|
760
|
+
snapshotErrorCount += after.errorCount;
|
|
761
|
+
if (changedFiles.length > 0) {
|
|
762
|
+
return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
if (observedTaskDirWrites.size > 0) {
|
|
767
|
+
return { progress: "unknown", changedFiles: [], snapshotTruncated, snapshotErrorCount };
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
return {
|
|
771
|
+
progress: snapshotTruncated || snapshotErrorCount > 0 ? "unknown" : false,
|
|
772
|
+
changedFiles,
|
|
773
|
+
snapshotTruncated,
|
|
774
|
+
snapshotErrorCount,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function summarizeChangedFiles(changedFiles: string[]): string {
|
|
779
|
+
if (changedFiles.length === 0) return "none";
|
|
780
|
+
const visible = changedFiles.slice(0, 5);
|
|
781
|
+
if (visible.length === changedFiles.length) return visible.join(", ");
|
|
782
|
+
return `${visible.join(", ")} (+${changedFiles.length - visible.length} more)`;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function summarizeSnapshotCoverage(truncated: boolean, errorCount: number): string {
|
|
786
|
+
const parts: string[] = [];
|
|
787
|
+
if (truncated) parts.push("snapshot truncated");
|
|
788
|
+
if (errorCount > 0) parts.push(errorCount === 1 ? "1 file unreadable" : `${errorCount} files unreadable`);
|
|
789
|
+
return parts.join(", ");
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function summarizeIterationProgress(summary: Pick<IterationSummary, "progress" | "changedFiles" | "snapshotTruncated" | "snapshotErrorCount">): string {
|
|
793
|
+
if (summary.progress === true) return `durable progress (${summarizeChangedFiles(summary.changedFiles)})`;
|
|
794
|
+
if (summary.progress === false) return "no durable progress";
|
|
795
|
+
const coverage = summarizeSnapshotCoverage(summary.snapshotTruncated ?? false, summary.snapshotErrorCount ?? 0);
|
|
796
|
+
return coverage ? `durable progress unknown (${coverage})` : "durable progress unknown";
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function summarizeLastIterationFeedback(summary: IterationSummary | undefined, fallbackNoProgressStreak: number): string {
|
|
800
|
+
if (!summary) return "";
|
|
801
|
+
if (summary.progress === true) {
|
|
802
|
+
return `Last iteration durable progress: ${summarizeChangedFiles(summary.changedFiles)}.`;
|
|
803
|
+
}
|
|
804
|
+
if (summary.progress === false) {
|
|
805
|
+
return `Last iteration made no durable progress. No-progress streak: ${summary.noProgressStreak ?? fallbackNoProgressStreak}.`;
|
|
806
|
+
}
|
|
807
|
+
const coverage = summarizeSnapshotCoverage(summary.snapshotTruncated ?? false, summary.snapshotErrorCount ?? 0);
|
|
808
|
+
const detail = coverage ? ` (${coverage})` : "";
|
|
809
|
+
return `Last iteration durable progress could not be verified${detail}. No-progress streak remains ${summary.noProgressStreak ?? fallbackNoProgressStreak}.`;
|
|
810
|
+
}
|
|
811
|
+
|
|
116
812
|
function writeDraftFile(ralphPath: string, content: string) {
|
|
117
813
|
mkdirSync(dirname(ralphPath), { recursive: true });
|
|
118
814
|
writeFileSync(ralphPath, content, "utf8");
|
|
@@ -123,14 +819,89 @@ function displayPath(cwd: string, filePath: string): string {
|
|
|
123
819
|
return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
|
|
124
820
|
}
|
|
125
821
|
|
|
126
|
-
|
|
822
|
+
function exportRalphLogs(taskDir: string, destDir: string): { iterations: number; events: number; transcripts: number } {
|
|
823
|
+
const runnerDir = join(taskDir, ".ralph-runner");
|
|
824
|
+
if (!existsSync(runnerDir)) {
|
|
825
|
+
throw new Error(`No .ralph-runner directory found at ${taskDir}`);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
mkdirSync(destDir, { recursive: true });
|
|
829
|
+
|
|
830
|
+
const filesToCopy = ["status.json", "iterations.jsonl", "events.jsonl"];
|
|
831
|
+
for (const file of filesToCopy) {
|
|
832
|
+
const src = join(runnerDir, file);
|
|
833
|
+
if (existsSync(src)) {
|
|
834
|
+
copyFileSync(src, join(destDir, file));
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Copy transcripts directory
|
|
839
|
+
const transcriptsDir = join(runnerDir, "transcripts");
|
|
840
|
+
let transcripts = 0;
|
|
841
|
+
if (existsSync(transcriptsDir)) {
|
|
842
|
+
const destTranscripts = join(destDir, "transcripts");
|
|
843
|
+
mkdirSync(destTranscripts, { recursive: true });
|
|
844
|
+
for (const entry of readdirSync(transcriptsDir)) {
|
|
845
|
+
const srcPath = join(transcriptsDir, entry);
|
|
846
|
+
try {
|
|
847
|
+
const stat = statSync(srcPath);
|
|
848
|
+
if (stat.isFile()) {
|
|
849
|
+
copyFileSync(srcPath, join(destTranscripts, entry));
|
|
850
|
+
transcripts++;
|
|
851
|
+
}
|
|
852
|
+
} catch {
|
|
853
|
+
// skip unreadable entries
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// Count iterations and events
|
|
859
|
+
let iterations = 0;
|
|
860
|
+
let events = 0;
|
|
861
|
+
const iterPath = join(destDir, "iterations.jsonl");
|
|
862
|
+
if (existsSync(iterPath)) {
|
|
863
|
+
iterations = readFileSync(iterPath, "utf8").split("\n").filter((l) => l.trim()).length;
|
|
864
|
+
}
|
|
865
|
+
const evPath = join(destDir, "events.jsonl");
|
|
866
|
+
if (existsSync(evPath)) {
|
|
867
|
+
events = readFileSync(evPath, "utf8").split("\n").filter((l) => l.trim()).length;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
return { iterations, events, transcripts };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
export function parseLogExportArgs(raw: string): { path?: string; dest?: string; error?: string } {
|
|
874
|
+
const parts = raw.trim().split(/\s+/);
|
|
875
|
+
let path: string | undefined;
|
|
876
|
+
let dest: string | undefined;
|
|
877
|
+
let i = 0;
|
|
878
|
+
while (i < parts.length) {
|
|
879
|
+
if (parts[i] === "--dest" || parts[i] === "-d") {
|
|
880
|
+
if (i + 1 >= parts.length) return { error: "--dest requires a directory path" };
|
|
881
|
+
dest = parts[i + 1];
|
|
882
|
+
i += 2;
|
|
883
|
+
} else if (parts[i] === "--path" || parts[i] === "-p") {
|
|
884
|
+
if (i + 1 >= parts.length) return { error: "--path requires a task path" };
|
|
885
|
+
path = parts[i + 1];
|
|
886
|
+
i += 2;
|
|
887
|
+
} else if (!path && parts[i]) {
|
|
888
|
+
path = parts[i];
|
|
889
|
+
i++;
|
|
890
|
+
} else {
|
|
891
|
+
i++;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
return { path, dest };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
async function promptForTask(ctx: Pick<CommandContext, "hasUI" | "ui">, title: string, placeholder: string): Promise<string | undefined> {
|
|
127
898
|
if (!ctx.hasUI) return undefined;
|
|
128
899
|
const value = await ctx.ui.input(title, placeholder);
|
|
129
900
|
const trimmed = value?.trim();
|
|
130
901
|
return trimmed ? trimmed : undefined;
|
|
131
902
|
}
|
|
132
903
|
|
|
133
|
-
async function reviewDraft(plan:
|
|
904
|
+
async function reviewDraft(plan: DraftPlan, mode: "run" | "draft", ctx: Pick<CommandContext, "ui">): Promise<{ action: "start" | "save" | "cancel"; content: string }> {
|
|
134
905
|
let content = plan.content;
|
|
135
906
|
|
|
136
907
|
while (true) {
|
|
@@ -162,7 +933,7 @@ async function reviewDraft(plan: ReturnType<typeof generateDraft>, mode: "run" |
|
|
|
162
933
|
}
|
|
163
934
|
}
|
|
164
935
|
|
|
165
|
-
async function editExistingDraft(ralphPath: string, ctx:
|
|
936
|
+
async function editExistingDraft(ralphPath: string, ctx: Pick<CommandContext, "cwd" | "hasUI" | "ui">, saveMessage = "Saved RALPH.md") {
|
|
166
937
|
if (!ctx.hasUI) {
|
|
167
938
|
ctx.ui.notify(`Use ${displayPath(ctx.cwd, ralphPath)} in an interactive session to edit the draft.`, "warning");
|
|
168
939
|
return;
|
|
@@ -194,7 +965,7 @@ async function editExistingDraft(ralphPath: string, ctx: any, saveMessage = "Sav
|
|
|
194
965
|
async function chooseRecoveryMode(
|
|
195
966
|
input: string,
|
|
196
967
|
dirPath: string,
|
|
197
|
-
ctx:
|
|
968
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
198
969
|
allowTaskFallback = true,
|
|
199
970
|
): Promise<"draft-path" | "task" | "cancel"> {
|
|
200
971
|
const options = allowTaskFallback ? ["Draft in that folder", "Treat as task text", "Cancel"] : ["Draft in that folder", "Cancel"];
|
|
@@ -204,7 +975,7 @@ async function chooseRecoveryMode(
|
|
|
204
975
|
return "cancel";
|
|
205
976
|
}
|
|
206
977
|
|
|
207
|
-
async function chooseConflictTarget(commandName: "ralph" | "ralph-draft", task: string, target: DraftTarget, ctx:
|
|
978
|
+
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 }> {
|
|
208
979
|
const hasExistingDraft = existsSync(target.ralphPath);
|
|
209
980
|
const title = hasExistingDraft
|
|
210
981
|
? `Found an existing RALPH at ${displayPath(ctx.cwd, target.ralphPath)} for “${task}”.`
|
|
@@ -225,8 +996,23 @@ async function chooseConflictTarget(commandName: "ralph" | "ralph-draft", task:
|
|
|
225
996
|
return { action: "draft-target", target: createSiblingTarget(ctx.cwd, target.slug) };
|
|
226
997
|
}
|
|
227
998
|
|
|
228
|
-
|
|
229
|
-
|
|
999
|
+
function getDraftStrengtheningRuntime(ctx: Pick<CommandContext, "model" | "modelRegistry">): StrengthenDraftRuntime | undefined {
|
|
1000
|
+
if (!ctx.model || !ctx.modelRegistry) return undefined;
|
|
1001
|
+
return {
|
|
1002
|
+
model: ctx.model,
|
|
1003
|
+
modelRegistry: ctx.modelRegistry,
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
async function draftFromTask(
|
|
1008
|
+
commandName: "ralph" | "ralph-draft",
|
|
1009
|
+
task: string,
|
|
1010
|
+
target: DraftTarget,
|
|
1011
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
1012
|
+
draftPlanFactory: DraftPlanFactory,
|
|
1013
|
+
runtime?: StrengthenDraftRuntime,
|
|
1014
|
+
): Promise<string | undefined> {
|
|
1015
|
+
const plan = await draftPlanFactory(task, target, ctx.cwd, runtime);
|
|
230
1016
|
const review = await reviewDraft(plan, commandName === "ralph" ? "run" : "draft", ctx);
|
|
231
1017
|
if (review.action === "cancel") return undefined;
|
|
232
1018
|
|
|
@@ -238,41 +1024,251 @@ async function draftFromTask(commandName: "ralph" | "ralph-draft", task: string,
|
|
|
238
1024
|
return target.ralphPath;
|
|
239
1025
|
}
|
|
240
1026
|
|
|
1027
|
+
function resolveSessionStopTarget(ctx: Pick<CommandContext, "cwd" | "sessionManager">, now: string): {
|
|
1028
|
+
target?: StopTarget;
|
|
1029
|
+
persistedSessionState?: ActiveLoopState;
|
|
1030
|
+
} {
|
|
1031
|
+
if (loopState.active) {
|
|
1032
|
+
return {
|
|
1033
|
+
target: {
|
|
1034
|
+
cwd: loopState.cwd || ctx.cwd,
|
|
1035
|
+
taskDir: loopState.taskDir,
|
|
1036
|
+
ralphPath: loopState.ralphPath,
|
|
1037
|
+
loopToken: loopState.loopToken ?? "",
|
|
1038
|
+
currentIteration: loopState.iteration,
|
|
1039
|
+
maxIterations: loopState.maxIterations,
|
|
1040
|
+
startedAt: now,
|
|
1041
|
+
source: "session",
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
const persistedSessionState = readActiveLoopState(ctx);
|
|
1047
|
+
if (
|
|
1048
|
+
!persistedSessionState ||
|
|
1049
|
+
typeof persistedSessionState.taskDir !== "string" ||
|
|
1050
|
+
persistedSessionState.taskDir.length === 0 ||
|
|
1051
|
+
typeof persistedSessionState.loopToken !== "string" ||
|
|
1052
|
+
persistedSessionState.loopToken.length === 0 ||
|
|
1053
|
+
typeof persistedSessionState.iteration !== "number" ||
|
|
1054
|
+
typeof persistedSessionState.maxIterations !== "number"
|
|
1055
|
+
) {
|
|
1056
|
+
return { persistedSessionState };
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
return {
|
|
1060
|
+
persistedSessionState,
|
|
1061
|
+
target: {
|
|
1062
|
+
cwd: typeof persistedSessionState.cwd === "string" && persistedSessionState.cwd.length > 0 ? persistedSessionState.cwd : ctx.cwd,
|
|
1063
|
+
taskDir: persistedSessionState.taskDir,
|
|
1064
|
+
ralphPath: join(persistedSessionState.taskDir, "RALPH.md"),
|
|
1065
|
+
loopToken: persistedSessionState.loopToken,
|
|
1066
|
+
currentIteration: persistedSessionState.iteration,
|
|
1067
|
+
maxIterations: persistedSessionState.maxIterations,
|
|
1068
|
+
startedAt: now,
|
|
1069
|
+
source: "session",
|
|
1070
|
+
},
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function materializeRegistryStopTarget(entry: ActiveLoopRegistryEntry): StopTarget {
|
|
1075
|
+
return {
|
|
1076
|
+
cwd: entry.cwd,
|
|
1077
|
+
taskDir: entry.taskDir,
|
|
1078
|
+
ralphPath: entry.ralphPath,
|
|
1079
|
+
loopToken: entry.loopToken,
|
|
1080
|
+
currentIteration: entry.currentIteration,
|
|
1081
|
+
maxIterations: entry.maxIterations,
|
|
1082
|
+
startedAt: entry.startedAt,
|
|
1083
|
+
source: "registry",
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function applyStopTarget(
|
|
1088
|
+
pi: ExtensionAPI,
|
|
1089
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
1090
|
+
target: StopTarget,
|
|
1091
|
+
now: string,
|
|
1092
|
+
persistedSessionState?: ActiveLoopState,
|
|
1093
|
+
): void {
|
|
1094
|
+
createStopSignal(target.taskDir);
|
|
1095
|
+
|
|
1096
|
+
const registryCwd = target.cwd;
|
|
1097
|
+
const existingEntry = readActiveLoopRegistry(registryCwd).find((entry) => entry.taskDir === target.taskDir);
|
|
1098
|
+
const registryEntry: ActiveLoopRegistryEntry = existingEntry
|
|
1099
|
+
? {
|
|
1100
|
+
...existingEntry,
|
|
1101
|
+
taskDir: target.taskDir,
|
|
1102
|
+
ralphPath: target.ralphPath,
|
|
1103
|
+
cwd: registryCwd,
|
|
1104
|
+
updatedAt: now,
|
|
1105
|
+
}
|
|
1106
|
+
: {
|
|
1107
|
+
taskDir: target.taskDir,
|
|
1108
|
+
ralphPath: target.ralphPath,
|
|
1109
|
+
cwd: registryCwd,
|
|
1110
|
+
loopToken: target.loopToken,
|
|
1111
|
+
status: "running",
|
|
1112
|
+
currentIteration: target.currentIteration,
|
|
1113
|
+
maxIterations: target.maxIterations,
|
|
1114
|
+
startedAt: target.startedAt,
|
|
1115
|
+
updatedAt: now,
|
|
1116
|
+
};
|
|
1117
|
+
writeActiveLoopRegistryEntry(registryCwd, registryEntry);
|
|
1118
|
+
recordActiveLoopStopRequest(registryCwd, target.taskDir, now);
|
|
1119
|
+
|
|
1120
|
+
if (target.source === "session") {
|
|
1121
|
+
loopState.stopRequested = true;
|
|
1122
|
+
if (loopState.active) {
|
|
1123
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: true, stopRequested: true }));
|
|
1124
|
+
} else if (persistedSessionState?.active) {
|
|
1125
|
+
persistLoopState(pi, { ...persistedSessionState, stopRequested: true });
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
1130
|
+
}
|
|
1131
|
+
|
|
241
1132
|
let loopState: LoopState = defaultLoopState();
|
|
1133
|
+
const RALPH_EXTENSION_REGISTERED = Symbol.for("pi-ralph-loop.registered");
|
|
1134
|
+
|
|
1135
|
+
function scaffoldRalphTemplate(): string {
|
|
1136
|
+
return `---
|
|
1137
|
+
max_iterations: 10
|
|
1138
|
+
timeout: 120
|
|
1139
|
+
commands: []
|
|
1140
|
+
---
|
|
1141
|
+
# {{ task.name }}
|
|
1142
|
+
|
|
1143
|
+
Describe the task here.
|
|
242
1144
|
|
|
243
|
-
|
|
1145
|
+
## Evidence
|
|
1146
|
+
Use {{ commands.* }} outputs as evidence.
|
|
1147
|
+
|
|
1148
|
+
## Completion
|
|
1149
|
+
Stop with <promise>DONE</promise> when finished.
|
|
1150
|
+
`;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function slugifyTaskName(text: string): string {
|
|
1154
|
+
return text
|
|
1155
|
+
.toLowerCase()
|
|
1156
|
+
.trim()
|
|
1157
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1158
|
+
.replace(/^-+|-+$/g, "");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
export default function (pi: ExtensionAPI, services: RegisterRalphCommandServices = {}) {
|
|
1162
|
+
const registeredPi = pi as ExtensionAPI & Record<symbol, boolean | undefined>;
|
|
1163
|
+
if (registeredPi[RALPH_EXTENSION_REGISTERED]) return;
|
|
1164
|
+
registeredPi[RALPH_EXTENSION_REGISTERED] = true;
|
|
244
1165
|
const failCounts = new Map<string, number>();
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
1166
|
+
const pendingIterations = new Map<string, PendingIterationState>();
|
|
1167
|
+
const draftPlanFactory = services.createDraftPlan ?? createDraftPlanService;
|
|
1168
|
+
const isLoopSession = (ctx: Pick<CommandContext, "sessionManager">): boolean => resolveActiveLoopState(ctx) !== undefined;
|
|
1169
|
+
const appendLoopProofEntry = (customType: string, data: Record<string, unknown>): void => {
|
|
1170
|
+
try {
|
|
1171
|
+
pi.appendEntry?.(customType, data);
|
|
1172
|
+
} catch (err) {
|
|
1173
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1174
|
+
try {
|
|
1175
|
+
process.stderr.write(`Ralph proof logging failed for ${customType}: ${message}\n`);
|
|
1176
|
+
} catch {
|
|
1177
|
+
// Best-effort surfacing only.
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
const getPendingIteration = (ctx: Pick<CommandContext, "sessionManager">): PendingIterationState | undefined => {
|
|
1182
|
+
const state = resolveActiveIterationState(ctx);
|
|
1183
|
+
return state ? pendingIterations.get(getLoopIterationKey(state.loopToken, state.iteration)) : undefined;
|
|
1184
|
+
};
|
|
1185
|
+
const registerPendingIteration = (loopToken: string, iteration: number, prompt: string): PendingIterationState => {
|
|
1186
|
+
const pending: PendingIterationState = {
|
|
1187
|
+
prompt,
|
|
1188
|
+
completion: createDeferred<IterationCompletion>(),
|
|
1189
|
+
toolCallPaths: new Map(),
|
|
1190
|
+
observedTaskDirWrites: new Set(),
|
|
1191
|
+
};
|
|
1192
|
+
pendingIterations.set(getLoopIterationKey(loopToken, iteration), pending);
|
|
1193
|
+
return pending;
|
|
1194
|
+
};
|
|
1195
|
+
const clearPendingIteration = (loopToken: string, iteration: number) => {
|
|
1196
|
+
pendingIterations.delete(getLoopIterationKey(loopToken, iteration));
|
|
1197
|
+
};
|
|
1198
|
+
const resolvePendingIteration = (ctx: EventContext, event: AgentEndEvent) => {
|
|
1199
|
+
const state = resolveActiveIterationState(ctx);
|
|
1200
|
+
if (!state) return;
|
|
1201
|
+
const pendingKey = getLoopIterationKey(state.loopToken, state.iteration);
|
|
1202
|
+
const pending = pendingIterations.get(pendingKey);
|
|
1203
|
+
if (!pending) return;
|
|
1204
|
+
pendingIterations.delete(pendingKey);
|
|
1205
|
+
const rawError = (event as { error?: unknown }).error;
|
|
1206
|
+
const error = rawError instanceof Error ? rawError : rawError ? new Error(String(rawError)) : undefined;
|
|
1207
|
+
pending.completion.resolve({
|
|
1208
|
+
messages: event.messages ?? [],
|
|
1209
|
+
observedTaskDirWrites: new Set(pending.observedTaskDirWrites),
|
|
1210
|
+
error,
|
|
1211
|
+
});
|
|
1212
|
+
};
|
|
1213
|
+
const recordPendingToolPath = (ctx: EventContext, event: ToolEvent) => {
|
|
1214
|
+
const pending = getPendingIteration(ctx);
|
|
1215
|
+
if (!pending) return;
|
|
1216
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
1217
|
+
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
1218
|
+
const filePath = event.input?.path ?? "";
|
|
1219
|
+
if (toolCallId && filePath) pending.toolCallPaths.set(toolCallId, filePath);
|
|
1220
|
+
};
|
|
1221
|
+
const recordSuccessfulTaskDirWrite = (ctx: EventContext, event: ToolEvent) => {
|
|
1222
|
+
const pending = getPendingIteration(ctx);
|
|
1223
|
+
if (!pending) return;
|
|
1224
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
1225
|
+
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
1226
|
+
const filePath = toolCallId ? pending.toolCallPaths.get(toolCallId) : undefined;
|
|
1227
|
+
if (toolCallId) pending.toolCallPaths.delete(toolCallId);
|
|
1228
|
+
if (event.isError === true || event.success === false || !filePath) return;
|
|
1229
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1230
|
+
const taskDirPath = persisted?.taskDir ?? loopState.taskDir;
|
|
1231
|
+
const cwd = persisted?.cwd ?? loopState.cwd;
|
|
1232
|
+
const relPath = resolveTaskDirObservedPath(taskDirPath ?? "", cwd ?? taskDirPath ?? "", filePath);
|
|
1233
|
+
if (relPath && relPath !== RALPH_PROGRESS_FILE) pending.observedTaskDirWrites.add(relPath);
|
|
249
1234
|
};
|
|
250
1235
|
|
|
251
|
-
async function startRalphLoop(ralphPath: string, ctx:
|
|
1236
|
+
async function startRalphLoop(ralphPath: string, ctx: CommandContext, runLoopFn: typeof runRalphLoop = runRalphLoop, runtimeArgs: RuntimeArgs = {}) {
|
|
252
1237
|
let name: string;
|
|
1238
|
+
let currentStopOnError = true;
|
|
253
1239
|
try {
|
|
254
1240
|
const raw = readFileSync(ralphPath, "utf8");
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
1241
|
+
const draftError = validateDraftContent(raw);
|
|
1242
|
+
if (draftError) {
|
|
1243
|
+
ctx.ui.notify(`Invalid RALPH.md: ${draftError}`, "error");
|
|
1244
|
+
return;
|
|
261
1245
|
}
|
|
262
|
-
const
|
|
1246
|
+
const parsed = parseRalphMarkdown(raw);
|
|
1247
|
+
const { frontmatter } = parsed;
|
|
263
1248
|
if (!validateFrontmatter(frontmatter, ctx)) return;
|
|
264
|
-
|
|
1249
|
+
currentStopOnError = frontmatter.stopOnError;
|
|
1250
|
+
const runtimeValidationError = validateRuntimeArgs(frontmatter, parsed.body, frontmatter.commands, runtimeArgs);
|
|
1251
|
+
if (runtimeValidationError) {
|
|
1252
|
+
ctx.ui.notify(runtimeValidationError, "error");
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
const taskDir = dirname(ralphPath);
|
|
1256
|
+
name = basename(taskDir);
|
|
265
1257
|
loopState = {
|
|
266
1258
|
active: true,
|
|
267
1259
|
ralphPath,
|
|
1260
|
+
taskDir,
|
|
1261
|
+
cwd: ctx.cwd,
|
|
268
1262
|
iteration: 0,
|
|
269
1263
|
maxIterations: frontmatter.maxIterations,
|
|
270
1264
|
timeout: frontmatter.timeout,
|
|
271
1265
|
completionPromise: frontmatter.completionPromise,
|
|
272
1266
|
stopRequested: false,
|
|
1267
|
+
noProgressStreak: 0,
|
|
273
1268
|
iterationSummaries: [],
|
|
274
1269
|
guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
|
|
275
|
-
|
|
1270
|
+
observedTaskDirWrites: new Set(),
|
|
1271
|
+
loopToken: randomUUID(),
|
|
276
1272
|
};
|
|
277
1273
|
} catch (err) {
|
|
278
1274
|
ctx.ui.notify(String(err), "error");
|
|
@@ -281,143 +1277,119 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
1277
|
ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
|
|
282
1278
|
|
|
283
1279
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
1280
|
+
const result = await runLoopFn({
|
|
1281
|
+
ralphPath,
|
|
1282
|
+
cwd: ctx.cwd,
|
|
1283
|
+
timeout: loopState.timeout,
|
|
1284
|
+
maxIterations: loopState.maxIterations,
|
|
1285
|
+
guardrails: loopState.guardrails,
|
|
1286
|
+
stopOnError: currentStopOnError,
|
|
1287
|
+
runtimeArgs,
|
|
1288
|
+
modelPattern: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
1289
|
+
thinkingLevel: ctx.model?.reasoning ? "high" : undefined,
|
|
1290
|
+
runCommandsFn: async (commands, blocked, commandPi, cwd, taskDir) => runCommands(commands, blocked, commandPi as ExtensionAPI, runtimeArgs, cwd, taskDir),
|
|
1291
|
+
onStatusChange(status) {
|
|
1292
|
+
ctx.ui.setStatus("ralph", status === "running" || status === "initializing" ? `🔁 ${name}: running` : undefined);
|
|
1293
|
+
},
|
|
1294
|
+
onNotify(message, level) {
|
|
1295
|
+
ctx.ui.notify(message, level);
|
|
1296
|
+
},
|
|
1297
|
+
onIterationComplete(record) {
|
|
1298
|
+
loopState.iteration = record.iteration;
|
|
1299
|
+
loopState.noProgressStreak = record.noProgressStreak;
|
|
1300
|
+
const summary: IterationSummary = {
|
|
1301
|
+
iteration: record.iteration,
|
|
1302
|
+
duration: record.durationMs ? Math.round(record.durationMs / 1000) : 0,
|
|
1303
|
+
progress: record.progress,
|
|
1304
|
+
changedFiles: record.changedFiles,
|
|
1305
|
+
noProgressStreak: record.noProgressStreak,
|
|
1306
|
+
};
|
|
1307
|
+
loopState.iterationSummaries.push(summary);
|
|
1308
|
+
pi.appendEntry("ralph-iteration", {
|
|
1309
|
+
iteration: record.iteration,
|
|
1310
|
+
duration: summary.duration,
|
|
1311
|
+
ralphPath: loopState.ralphPath,
|
|
1312
|
+
progress: record.progress,
|
|
1313
|
+
changedFiles: record.changedFiles,
|
|
1314
|
+
noProgressStreak: record.noProgressStreak,
|
|
1315
|
+
});
|
|
1316
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: true, stopRequested: false }));
|
|
1317
|
+
},
|
|
1318
|
+
pi,
|
|
1319
|
+
});
|
|
292
1320
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
ctx.ui.notify(`
|
|
1321
|
+
// Map runner result to UI notifications
|
|
1322
|
+
const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
|
|
1323
|
+
switch (result.status) {
|
|
1324
|
+
case "complete":
|
|
1325
|
+
ctx.ui.notify(`Ralph loop complete: completion promise matched on iteration ${result.iterations.length} (${total}s total)`, "info");
|
|
298
1326
|
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");
|
|
1327
|
+
case "max-iterations":
|
|
1328
|
+
ctx.ui.notify(`Ralph loop reached max iterations: ${result.iterations.length} iterations, ${total}s total`, "info");
|
|
319
1329
|
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");
|
|
1330
|
+
case "no-progress-exhaustion":
|
|
1331
|
+
ctx.ui.notify(`Ralph loop exhausted without verified progress: ${result.iterations.length} iterations, ${total}s total`, "warning");
|
|
361
1332
|
break;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
ctx.ui.notify(`Iteration ${i} agent error: ${idleError!.message}, stopping loop`, "error");
|
|
1333
|
+
case "stopped":
|
|
1334
|
+
ctx.ui.notify(`Ralph loop stopped: ${result.iterations.length} iterations, ${total}s total`, "info");
|
|
365
1335
|
break;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
loopState.stopRequested = true;
|
|
375
|
-
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
1336
|
+
case "timeout":
|
|
1337
|
+
ctx.ui.notify(`Ralph loop stopped after a timeout: ${result.iterations.length} iterations, ${total}s total`, "warning");
|
|
1338
|
+
break;
|
|
1339
|
+
case "error":
|
|
1340
|
+
ctx.ui.notify(`Ralph loop failed: ${result.iterations.length} iterations, ${total}s total`, "error");
|
|
1341
|
+
break;
|
|
1342
|
+
default:
|
|
1343
|
+
ctx.ui.notify(`Ralph loop ended: ${result.status} (${total}s total)`, "info");
|
|
376
1344
|
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
1345
|
}
|
|
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
1346
|
} catch (err) {
|
|
398
1347
|
const message = err instanceof Error ? err.message : String(err);
|
|
399
1348
|
ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
|
|
400
1349
|
} finally {
|
|
401
1350
|
failCounts.clear();
|
|
1351
|
+
pendingIterations.clear();
|
|
402
1352
|
loopState.active = false;
|
|
403
1353
|
loopState.stopRequested = false;
|
|
404
|
-
loopState.
|
|
1354
|
+
loopState.loopToken = undefined;
|
|
405
1355
|
ctx.ui.setStatus("ralph", undefined);
|
|
406
|
-
persistLoopState(pi, { active: false });
|
|
1356
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: false, stopRequested: false }));
|
|
407
1357
|
}
|
|
408
1358
|
}
|
|
409
1359
|
|
|
410
|
-
|
|
1360
|
+
let runtimeArgsForStart: RuntimeArgs = {};
|
|
1361
|
+
|
|
1362
|
+
async function handleDraftCommand(commandName: "ralph" | "ralph-draft", args: string, ctx: CommandContext): Promise<string | undefined> {
|
|
411
1363
|
const parsed = parseCommandArgs(args);
|
|
1364
|
+
if (parsed.error) {
|
|
1365
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1366
|
+
return undefined;
|
|
1367
|
+
}
|
|
1368
|
+
const runtimeArgsResult = runtimeArgEntriesToMap(parsed.runtimeArgs);
|
|
1369
|
+
if (runtimeArgsResult.error) {
|
|
1370
|
+
ctx.ui.notify(runtimeArgsResult.error, "error");
|
|
1371
|
+
return undefined;
|
|
1372
|
+
}
|
|
1373
|
+
const runtimeArgs = runtimeArgsResult.runtimeArgs;
|
|
1374
|
+
if (parsed.runtimeArgs.length > 0 && (commandName === "ralph-draft" || parsed.mode !== "path")) {
|
|
1375
|
+
ctx.ui.notify("--arg is only supported with /ralph --path", "error");
|
|
1376
|
+
return undefined;
|
|
1377
|
+
}
|
|
1378
|
+
runtimeArgsForStart = runtimeArgs;
|
|
1379
|
+
const draftRuntime = getDraftStrengtheningRuntime(ctx);
|
|
412
1380
|
|
|
413
1381
|
const resolveTaskForFolder = async (target: DraftTarget): Promise<string | undefined> => {
|
|
414
1382
|
const task = await promptForTask(ctx, "What should Ralph work on in this folder?", "reverse engineer this app");
|
|
415
1383
|
if (!task) return undefined;
|
|
416
|
-
return draftFromTask(commandName, task, target, ctx);
|
|
1384
|
+
return draftFromTask(commandName, task, target, ctx, draftPlanFactory, draftRuntime);
|
|
417
1385
|
};
|
|
418
1386
|
|
|
419
|
-
const handleExistingInspection = async (input: string, explicitPath = false): Promise<string | undefined> => {
|
|
1387
|
+
const handleExistingInspection = async (input: string, explicitPath = false, runtimeArgsProvided = false): Promise<string | undefined> => {
|
|
420
1388
|
const inspection = inspectExistingTarget(input, ctx.cwd, explicitPath);
|
|
1389
|
+
if (runtimeArgsProvided && inspection.kind !== "run") {
|
|
1390
|
+
ctx.ui.notify("--arg is only supported with /ralph --path to an existing RALPH.md", "error");
|
|
1391
|
+
return undefined;
|
|
1392
|
+
}
|
|
421
1393
|
switch (inspection.kind) {
|
|
422
1394
|
case "run":
|
|
423
1395
|
if (commandName === "ralph") return inspection.ralphPath;
|
|
@@ -466,14 +1438,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
466
1438
|
}
|
|
467
1439
|
planned = { kind: "draft", target: decision.target! };
|
|
468
1440
|
}
|
|
469
|
-
return draftFromTask(commandName, task, planned.target, ctx);
|
|
1441
|
+
return draftFromTask(commandName, task, planned.target, ctx, draftPlanFactory, draftRuntime);
|
|
470
1442
|
};
|
|
471
1443
|
|
|
472
1444
|
if (parsed.mode === "task") {
|
|
473
1445
|
return handleTaskFlow(parsed.value);
|
|
474
1446
|
}
|
|
475
1447
|
if (parsed.mode === "path") {
|
|
476
|
-
return handleExistingInspection(parsed.value || ".", true);
|
|
1448
|
+
return handleExistingInspection(parsed.value || ".", true, parsed.runtimeArgs.length > 0);
|
|
477
1449
|
}
|
|
478
1450
|
if (!parsed.value) {
|
|
479
1451
|
const inspection = inspectExistingTarget(".", ctx.cwd);
|
|
@@ -491,49 +1463,107 @@ export default function (pi: ExtensionAPI) {
|
|
|
491
1463
|
return handleExistingInspection(parsed.value);
|
|
492
1464
|
}
|
|
493
1465
|
|
|
494
|
-
pi.on("tool_call", async (event:
|
|
495
|
-
|
|
496
|
-
const persisted = readPersistedLoopState(ctx);
|
|
1466
|
+
pi.on("tool_call", async (event: ToolEvent, ctx: EventContext) => {
|
|
1467
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
497
1468
|
if (!persisted) return;
|
|
498
1469
|
|
|
1470
|
+
if (persisted.envMalformed && (event.toolName === "bash" || event.toolName === "write" || event.toolName === "edit")) {
|
|
1471
|
+
return { block: true, reason: "ralph: invalid loop contract" };
|
|
1472
|
+
}
|
|
1473
|
+
|
|
499
1474
|
if (event.toolName === "bash") {
|
|
500
1475
|
const cmd = (event.input as { command?: string }).command ?? "";
|
|
501
1476
|
const blockedPattern = findBlockedCommandPattern(cmd, persisted.guardrails?.blockCommands ?? []);
|
|
502
|
-
if (blockedPattern)
|
|
1477
|
+
if (blockedPattern) {
|
|
1478
|
+
appendLoopProofEntry("ralph-blocked-command", {
|
|
1479
|
+
loopToken: persisted.loopToken,
|
|
1480
|
+
iteration: persisted.iteration,
|
|
1481
|
+
command: cmd,
|
|
1482
|
+
blockedPattern,
|
|
1483
|
+
});
|
|
1484
|
+
return { block: true, reason: `ralph: blocked (${blockedPattern})` };
|
|
1485
|
+
}
|
|
503
1486
|
}
|
|
504
1487
|
|
|
505
1488
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
506
1489
|
const filePath = (event.input as { path?: string }).path ?? "";
|
|
507
|
-
|
|
508
|
-
|
|
1490
|
+
if (matchesProtectedPath(filePath, persisted.guardrails?.protectedFiles ?? [], persisted.cwd)) {
|
|
1491
|
+
appendLoopProofEntry("ralph-blocked-write", {
|
|
1492
|
+
loopToken: persisted.loopToken,
|
|
1493
|
+
iteration: persisted.iteration,
|
|
1494
|
+
toolName: event.toolName,
|
|
1495
|
+
path: filePath,
|
|
1496
|
+
reason: `ralph: ${filePath} is protected`,
|
|
1497
|
+
});
|
|
1498
|
+
return { block: true, reason: `ralph: ${filePath} is protected` };
|
|
509
1499
|
}
|
|
510
1500
|
}
|
|
1501
|
+
|
|
1502
|
+
recordPendingToolPath(ctx, event);
|
|
1503
|
+
});
|
|
1504
|
+
|
|
1505
|
+
pi.on("tool_execution_start", async (event: ToolEvent, ctx: EventContext) => {
|
|
1506
|
+
recordPendingToolPath(ctx, event);
|
|
1507
|
+
});
|
|
1508
|
+
|
|
1509
|
+
pi.on("tool_execution_end", async (event: ToolEvent, ctx: EventContext) => {
|
|
1510
|
+
recordSuccessfulTaskDirWrite(ctx, event);
|
|
511
1511
|
});
|
|
512
1512
|
|
|
513
|
-
pi.on("
|
|
514
|
-
|
|
515
|
-
|
|
1513
|
+
pi.on("agent_end", async (event: AgentEndEvent, ctx: EventContext) => {
|
|
1514
|
+
resolvePendingIteration(ctx, event);
|
|
1515
|
+
});
|
|
1516
|
+
|
|
1517
|
+
pi.on("before_agent_start", async (event: BeforeAgentStartEvent, ctx: EventContext) => {
|
|
1518
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1519
|
+
if (!persisted) return;
|
|
516
1520
|
const summaries = persisted?.iterationSummaries ?? [];
|
|
517
1521
|
if (summaries.length === 0) return;
|
|
518
1522
|
|
|
519
|
-
const history = summaries
|
|
1523
|
+
const history = summaries
|
|
1524
|
+
.map((summary) => {
|
|
1525
|
+
const status = summarizeIterationProgress(summary);
|
|
1526
|
+
return `- Iteration ${summary.iteration}: ${summary.duration}s — ${status}; no-progress streak: ${summary.noProgressStreak ?? persisted?.noProgressStreak ?? 0}`;
|
|
1527
|
+
})
|
|
1528
|
+
.join("\n");
|
|
1529
|
+
const lastSummary = summaries[summaries.length - 1];
|
|
1530
|
+
const lastFeedback = summarizeLastIterationFeedback(lastSummary, persisted?.noProgressStreak ?? 0);
|
|
1531
|
+
const taskDirLabel = persisted?.taskDir ? displayPath(persisted.cwd ?? persisted.taskDir, persisted.taskDir) : "the Ralph task directory";
|
|
1532
|
+
appendLoopProofEntry("ralph-steering-injected", {
|
|
1533
|
+
loopToken: persisted?.loopToken,
|
|
1534
|
+
iteration: persisted?.iteration,
|
|
1535
|
+
maxIterations: persisted?.maxIterations,
|
|
1536
|
+
taskDir: taskDirLabel,
|
|
1537
|
+
});
|
|
1538
|
+
appendLoopProofEntry("ralph-loop-context-injected", {
|
|
1539
|
+
loopToken: persisted?.loopToken,
|
|
1540
|
+
iteration: persisted?.iteration,
|
|
1541
|
+
maxIterations: persisted?.maxIterations,
|
|
1542
|
+
taskDir: taskDirLabel,
|
|
1543
|
+
summaryCount: summaries.length,
|
|
1544
|
+
});
|
|
1545
|
+
|
|
520
1546
|
return {
|
|
521
1547
|
systemPrompt:
|
|
522
1548
|
event.systemPrompt +
|
|
523
|
-
`\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.`,
|
|
1549
|
+
`\n\n## Ralph Loop Context\nIteration ${persisted?.iteration ?? 0}/${persisted?.maxIterations ?? 0}\nTask directory: ${taskDirLabel}\n\nPrevious iterations:\n${history}\n\n${lastFeedback}\nPersist findings to files in the Ralph task directory. Do not only report them in chat. If you make progress this iteration, leave durable file changes and mention the changed paths.\nDo not repeat completed work. Check git log for recent changes.`,
|
|
524
1550
|
};
|
|
525
1551
|
});
|
|
526
1552
|
|
|
527
|
-
pi.on("tool_result", async (event:
|
|
528
|
-
|
|
529
|
-
|
|
1553
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx: EventContext) => {
|
|
1554
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1555
|
+
if (!persisted) return;
|
|
1556
|
+
|
|
1557
|
+
if (event.toolName !== "bash") return;
|
|
1558
|
+
const output = event.content.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("");
|
|
530
1559
|
if (!shouldWarnForBashFailure(output)) return;
|
|
531
1560
|
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
1561
|
+
const state = resolveActiveIterationState(ctx);
|
|
1562
|
+
if (!state) return;
|
|
534
1563
|
|
|
535
|
-
const
|
|
536
|
-
failCounts.
|
|
1564
|
+
const failKey = getLoopIterationKey(state.loopToken, state.iteration);
|
|
1565
|
+
const next = (failCounts.get(failKey) ?? 0) + 1;
|
|
1566
|
+
failCounts.set(failKey, next);
|
|
537
1567
|
if (next >= 3) {
|
|
538
1568
|
return {
|
|
539
1569
|
content: [
|
|
@@ -546,7 +1576,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
546
1576
|
|
|
547
1577
|
pi.registerCommand("ralph", {
|
|
548
1578
|
description: "Start Ralph from a task folder or RALPH.md",
|
|
549
|
-
handler: async (args: string, ctx:
|
|
1579
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
550
1580
|
if (loopState.active) {
|
|
551
1581
|
ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
|
|
552
1582
|
return;
|
|
@@ -554,33 +1584,204 @@ export default function (pi: ExtensionAPI) {
|
|
|
554
1584
|
|
|
555
1585
|
const ralphPath = await handleDraftCommand("ralph", args ?? "", ctx);
|
|
556
1586
|
if (!ralphPath) return;
|
|
557
|
-
await startRalphLoop(ralphPath, ctx);
|
|
1587
|
+
await startRalphLoop(ralphPath, ctx, services.runRalphLoopFn, runtimeArgsForStart);
|
|
558
1588
|
},
|
|
559
1589
|
});
|
|
560
1590
|
|
|
561
1591
|
pi.registerCommand("ralph-draft", {
|
|
562
1592
|
description: "Draft a Ralph task without starting it",
|
|
563
|
-
handler: async (args: string, ctx:
|
|
1593
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
564
1594
|
await handleDraftCommand("ralph-draft", args ?? "", ctx);
|
|
565
1595
|
},
|
|
566
1596
|
});
|
|
567
1597
|
|
|
568
1598
|
pi.registerCommand("ralph-stop", {
|
|
569
1599
|
description: "Stop the ralph loop after the current iteration",
|
|
570
|
-
handler: async (
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
573
|
-
|
|
574
|
-
|
|
1600
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1601
|
+
const parsed = parseCommandArgs(args ?? "");
|
|
1602
|
+
if (parsed.error) {
|
|
1603
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
if (parsed.mode === "task") {
|
|
1607
|
+
ctx.ui.notify("/ralph-stop expects a task folder or RALPH.md path, not task text.", "error");
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
const now = new Date().toISOString();
|
|
1612
|
+
const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
|
|
1613
|
+
const { target: sessionTarget, persistedSessionState } = resolveSessionStopTarget(ctx, now);
|
|
1614
|
+
|
|
1615
|
+
if (sessionTarget && !parsed.value) {
|
|
1616
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1620
|
+
if (parsed.value) {
|
|
1621
|
+
const inspection = inspectExistingTarget(parsed.value, ctx.cwd, true);
|
|
1622
|
+
if (inspection.kind !== "run") {
|
|
1623
|
+
if (inspection.kind === "invalid-markdown") {
|
|
1624
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is not stoppable.`, "error");
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
if (inspection.kind === "invalid-target") {
|
|
1628
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
|
|
1629
|
+
return;
|
|
1630
|
+
}
|
|
1631
|
+
if (inspection.kind === "dir-without-ralph" || inspection.kind === "missing-path") {
|
|
1632
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.dirPath)}.`, "warning");
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
ctx.ui.notify("/ralph-stop expects a task folder or RALPH.md path.", "error");
|
|
1636
|
+
return;
|
|
1637
|
+
}
|
|
1638
|
+
|
|
1639
|
+
const taskDir = dirname(inspection.ralphPath);
|
|
1640
|
+
if (sessionTarget && sessionTarget.taskDir === taskDir) {
|
|
1641
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir || entry.ralphPath === inspection.ralphPath);
|
|
1646
|
+
if (registryTarget) {
|
|
1647
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(registryTarget), now);
|
|
575
1648
|
return;
|
|
576
1649
|
}
|
|
577
|
-
|
|
578
|
-
|
|
1650
|
+
|
|
1651
|
+
const statusFile = readStatusFile(taskDir);
|
|
1652
|
+
if (
|
|
1653
|
+
statusFile &&
|
|
1654
|
+
(statusFile.status === "running" || statusFile.status === "initializing") &&
|
|
1655
|
+
typeof statusFile.cwd === "string" &&
|
|
1656
|
+
statusFile.cwd.length > 0
|
|
1657
|
+
) {
|
|
1658
|
+
const statusRegistryTarget = listActiveLoopRegistryEntries(statusFile.cwd).find(
|
|
1659
|
+
(entry) => entry.taskDir === taskDir && entry.loopToken === statusFile.loopToken,
|
|
1660
|
+
);
|
|
1661
|
+
if (statusRegistryTarget) {
|
|
1662
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(statusRegistryTarget), now);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.ralphPath)}.`, "warning");
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
if (sessionTarget) {
|
|
1672
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1673
|
+
return;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
const activeEntries = activeRegistryEntries();
|
|
1677
|
+
if (activeEntries.length === 0) {
|
|
1678
|
+
ctx.ui.notify("No active ralph loops found.", "warning");
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1681
|
+
if (activeEntries.length > 1) {
|
|
1682
|
+
ctx.ui.notify("Multiple active ralph loops found. Use /ralph-stop --path <task folder or RALPH.md> for an explicit target path.", "error");
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
|
|
1686
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(activeEntries[0]), now);
|
|
1687
|
+
},
|
|
1688
|
+
});
|
|
1689
|
+
|
|
1690
|
+
pi.registerCommand("ralph-cancel", {
|
|
1691
|
+
description: "Cancel the active ralph iteration immediately",
|
|
1692
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1693
|
+
const parsed = parseCommandArgs(args ?? "");
|
|
1694
|
+
if (parsed.error) {
|
|
1695
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
if (parsed.mode === "task") {
|
|
1699
|
+
ctx.ui.notify("/ralph-cancel expects a task folder or RALPH.md path, not task text.", "error");
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
const result = resolveRalphTarget(ctx, {
|
|
1704
|
+
commandName: "/ralph-cancel",
|
|
1705
|
+
explicitPath: parsed.value || undefined,
|
|
1706
|
+
checkCrossProcess: true,
|
|
1707
|
+
});
|
|
1708
|
+
if (!result || result.kind === "not-found") return;
|
|
1709
|
+
|
|
1710
|
+
createCancelSignal(result.taskDir);
|
|
1711
|
+
ctx.ui.notify("Cancel requested. The active iteration will be terminated immediately.", "warning");
|
|
1712
|
+
},
|
|
1713
|
+
});
|
|
1714
|
+
|
|
1715
|
+
pi.registerCommand("ralph-scaffold", {
|
|
1716
|
+
description: "Create a non-interactive RALPH.md starter template",
|
|
1717
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1718
|
+
const name = (args ?? "").trim();
|
|
1719
|
+
if (!name) {
|
|
1720
|
+
ctx.ui.notify("/ralph-scaffold expects a task name or path.", "error");
|
|
1721
|
+
return;
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
let taskDir: string;
|
|
1725
|
+
let ralphPath: string;
|
|
1726
|
+
|
|
1727
|
+
if (name.includes("/") || name.startsWith("./")) {
|
|
1728
|
+
ralphPath = resolve(ctx.cwd, name.endsWith("/RALPH.md") ? name : join(name, "RALPH.md"));
|
|
1729
|
+
taskDir = dirname(ralphPath);
|
|
1730
|
+
} else {
|
|
1731
|
+
const slug = slugifyTaskName(name);
|
|
1732
|
+
if (!slug) {
|
|
1733
|
+
ctx.ui.notify(`Cannot slugify "${name}" into a valid directory name.`, "error");
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
taskDir = join(ctx.cwd, slug);
|
|
1737
|
+
ralphPath = join(taskDir, "RALPH.md");
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (existsSync(ralphPath)) {
|
|
1741
|
+
ctx.ui.notify(`RALPH.md already exists at ${displayPath(ctx.cwd, ralphPath)}. Not overwriting.`, "error");
|
|
1742
|
+
return;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
if (existsSync(taskDir) && readdirSync(taskDir).length > 0) {
|
|
1746
|
+
ctx.ui.notify(`Directory ${displayPath(ctx.cwd, taskDir)} already exists and is not empty. Not overwriting.`, "error");
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
mkdirSync(taskDir, { recursive: true });
|
|
1751
|
+
writeFileSync(ralphPath, scaffoldRalphTemplate(), "utf8");
|
|
1752
|
+
ctx.ui.notify(`Scaffolded ${displayPath(ctx.cwd, ralphPath)}`, "info");
|
|
1753
|
+
},
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1756
|
+
pi.registerCommand("ralph-logs", {
|
|
1757
|
+
description: "Export run logs from a ralph task to an external directory",
|
|
1758
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1759
|
+
const parsed = parseLogExportArgs(args ?? "");
|
|
1760
|
+
if (parsed.error) {
|
|
1761
|
+
ctx.ui.notify(parsed.error, "error");
|
|
579
1762
|
return;
|
|
580
1763
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1764
|
+
|
|
1765
|
+
const resolvedTarget = resolveRalphTarget(ctx, {
|
|
1766
|
+
commandName: "/ralph-logs",
|
|
1767
|
+
explicitPath: parsed.path,
|
|
1768
|
+
allowCompletedRuns: true,
|
|
1769
|
+
});
|
|
1770
|
+
if (!resolvedTarget || resolvedTarget.kind === "not-found") return;
|
|
1771
|
+
const taskDir = resolvedTarget.taskDir;
|
|
1772
|
+
|
|
1773
|
+
// Resolve dest directory
|
|
1774
|
+
const destDir = parsed.dest
|
|
1775
|
+
? resolve(ctx.cwd, parsed.dest)
|
|
1776
|
+
: join(ctx.cwd, `ralph-logs-${new Date().toISOString().replace(/[:.]/g, "-")}`);
|
|
1777
|
+
|
|
1778
|
+
try {
|
|
1779
|
+
const result = exportRalphLogs(taskDir, destDir);
|
|
1780
|
+
ctx.ui.notify(`Exported ${result.iterations} iteration records, ${result.events} events, ${result.transcripts} transcripts to ${displayPath(ctx.cwd, destDir)}`, "info");
|
|
1781
|
+
} catch (err) {
|
|
1782
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1783
|
+
ctx.ui.notify(`Log export failed: ${message}`, "error");
|
|
1784
|
+
}
|
|
584
1785
|
},
|
|
585
1786
|
});
|
|
586
1787
|
}
|