@lnilluv/pi-ralph-loop 0.3.0 → 1.0.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 +50 -160
- package/package.json +2 -2
- package/scripts/version-helper.ts +210 -0
- package/src/index.ts +1085 -188
- 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 +917 -102
- package/src/runner-rpc.ts +434 -0
- package/src/runner-state.ts +822 -0
- package/src/runner.ts +957 -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 +3529 -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 +1389 -19
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +358 -0
- package/tests/runner-state.test.ts +553 -0
- package/tests/runner.test.ts +1347 -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,147 @@
|
|
|
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 { existsSync, mkdirSync, readFileSync, readdirSync, 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
|
+
listActiveLoopRegistryEntries,
|
|
34
|
+
readActiveLoopRegistry,
|
|
35
|
+
readIterationRecords,
|
|
36
|
+
readStatusFile,
|
|
37
|
+
recordActiveLoopStopRequest,
|
|
38
|
+
writeActiveLoopRegistryEntry,
|
|
39
|
+
type ActiveLoopRegistryEntry,
|
|
40
|
+
} from "./runner-state.ts";
|
|
41
|
+
|
|
42
|
+
type ProgressState = boolean | "unknown";
|
|
43
|
+
|
|
44
|
+
type IterationSummary = {
|
|
45
|
+
iteration: number;
|
|
46
|
+
duration: number;
|
|
47
|
+
progress: ProgressState;
|
|
48
|
+
changedFiles: string[];
|
|
49
|
+
noProgressStreak: number;
|
|
50
|
+
snapshotTruncated?: boolean;
|
|
51
|
+
snapshotErrorCount?: number;
|
|
52
|
+
};
|
|
26
53
|
|
|
27
54
|
type LoopState = {
|
|
28
55
|
active: boolean;
|
|
29
56
|
ralphPath: string;
|
|
57
|
+
taskDir: string;
|
|
58
|
+
cwd: string;
|
|
30
59
|
iteration: number;
|
|
31
60
|
maxIterations: number;
|
|
32
61
|
timeout: number;
|
|
33
62
|
completionPromise?: string;
|
|
34
63
|
stopRequested: boolean;
|
|
35
|
-
|
|
64
|
+
noProgressStreak: number;
|
|
65
|
+
iterationSummaries: IterationSummary[];
|
|
36
66
|
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
37
|
-
|
|
67
|
+
observedTaskDirWrites: Set<string>;
|
|
68
|
+
loopToken?: string;
|
|
38
69
|
};
|
|
39
70
|
type PersistedLoopState = {
|
|
40
71
|
active: boolean;
|
|
41
|
-
|
|
72
|
+
loopToken?: string;
|
|
73
|
+
cwd?: string;
|
|
74
|
+
taskDir?: string;
|
|
42
75
|
iteration?: number;
|
|
43
76
|
maxIterations?: number;
|
|
44
|
-
|
|
77
|
+
noProgressStreak?: number;
|
|
78
|
+
iterationSummaries?: IterationSummary[];
|
|
45
79
|
guardrails?: { blockCommands: string[]; protectedFiles: string[] };
|
|
46
80
|
stopRequested?: boolean;
|
|
47
81
|
};
|
|
48
82
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
83
|
+
type ActiveLoopState = PersistedLoopState & { active: true; loopToken: string; envMalformed?: boolean };
|
|
84
|
+
type ActiveIterationState = ActiveLoopState & { iteration: number };
|
|
85
|
+
|
|
86
|
+
const RALPH_RUNNER_TASK_DIR_ENV = "RALPH_RUNNER_TASK_DIR";
|
|
87
|
+
const RALPH_RUNNER_CWD_ENV = "RALPH_RUNNER_CWD";
|
|
88
|
+
const RALPH_RUNNER_LOOP_TOKEN_ENV = "RALPH_RUNNER_LOOP_TOKEN";
|
|
89
|
+
const RALPH_RUNNER_CURRENT_ITERATION_ENV = "RALPH_RUNNER_CURRENT_ITERATION";
|
|
90
|
+
const RALPH_RUNNER_MAX_ITERATIONS_ENV = "RALPH_RUNNER_MAX_ITERATIONS";
|
|
91
|
+
const RALPH_RUNNER_NO_PROGRESS_STREAK_ENV = "RALPH_RUNNER_NO_PROGRESS_STREAK";
|
|
92
|
+
const RALPH_RUNNER_GUARDRAILS_ENV = "RALPH_RUNNER_GUARDRAILS";
|
|
93
|
+
|
|
94
|
+
type CommandContext = ExtensionCommandContext;
|
|
95
|
+
type CommandSessionEntry = SessionEntry;
|
|
96
|
+
|
|
97
|
+
type DraftPlanFactory = (
|
|
98
|
+
task: string,
|
|
99
|
+
target: DraftTarget,
|
|
100
|
+
cwd: string,
|
|
101
|
+
runtime?: StrengthenDraftRuntime,
|
|
102
|
+
) => Promise<DraftPlan>;
|
|
103
|
+
|
|
104
|
+
type RegisterRalphCommandServices = {
|
|
105
|
+
createDraftPlan?: DraftPlanFactory;
|
|
106
|
+
runRalphLoopFn?: typeof runRalphLoop;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type StopTargetSource = "session" | "registry" | "status";
|
|
110
|
+
|
|
111
|
+
type StopTarget = {
|
|
112
|
+
cwd: string;
|
|
113
|
+
taskDir: string;
|
|
114
|
+
ralphPath: string;
|
|
115
|
+
loopToken: string;
|
|
116
|
+
currentIteration: number;
|
|
117
|
+
maxIterations: number;
|
|
118
|
+
startedAt: string;
|
|
119
|
+
source: StopTargetSource;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type ToolEvent = {
|
|
123
|
+
toolName?: string;
|
|
124
|
+
toolCallId?: string;
|
|
125
|
+
input?: {
|
|
126
|
+
path?: string;
|
|
127
|
+
command?: string;
|
|
128
|
+
};
|
|
129
|
+
isError?: boolean;
|
|
130
|
+
success?: boolean;
|
|
131
|
+
};
|
|
52
132
|
|
|
53
|
-
|
|
133
|
+
type AgentEndEvent = PiAgentEndEvent;
|
|
134
|
+
|
|
135
|
+
type ToolResultEvent = PiToolResultEvent;
|
|
136
|
+
|
|
137
|
+
type BeforeAgentStartEvent = {
|
|
138
|
+
systemPrompt: string;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
type EventContext = Pick<CommandContext, "sessionManager">;
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
function validateFrontmatter(fm: Frontmatter, ctx: Pick<CommandContext, "ui">): boolean {
|
|
54
145
|
const error = validateFrontmatterMessage(fm);
|
|
55
146
|
if (error) {
|
|
56
147
|
ctx.ui.notify(error, "error");
|
|
@@ -59,17 +150,30 @@ function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
|
|
|
59
150
|
return true;
|
|
60
151
|
}
|
|
61
152
|
|
|
62
|
-
export async function runCommands(
|
|
153
|
+
export async function runCommands(
|
|
154
|
+
commands: CommandDef[],
|
|
155
|
+
blockPatterns: string[],
|
|
156
|
+
pi: ExtensionAPI,
|
|
157
|
+
runtimeArgs: RuntimeArgs = {},
|
|
158
|
+
cwd?: string,
|
|
159
|
+
taskDir?: string,
|
|
160
|
+
): Promise<CommandOutput[]> {
|
|
161
|
+
const repoCwd = cwd ?? process.cwd();
|
|
63
162
|
const results: CommandOutput[] = [];
|
|
64
163
|
for (const cmd of commands) {
|
|
65
|
-
const
|
|
164
|
+
const semanticRun = replaceArgsPlaceholders(cmd.run, runtimeArgs);
|
|
165
|
+
const blockedPattern = findBlockedCommandPattern(semanticRun, blockPatterns);
|
|
166
|
+
const resolvedRun = resolveCommandRun(cmd.run, runtimeArgs);
|
|
66
167
|
if (blockedPattern) {
|
|
168
|
+
pi.appendEntry?.("ralph-blocked-command", { name: cmd.name, command: semanticRun, blockedPattern, cwd: repoCwd, taskDir });
|
|
67
169
|
results.push({ name: cmd.name, output: `[blocked by guardrail: ${blockedPattern}]` });
|
|
68
170
|
continue;
|
|
69
171
|
}
|
|
70
172
|
|
|
173
|
+
const commandCwd = semanticRun.trim().startsWith("./") ? taskDir ?? repoCwd : repoCwd;
|
|
174
|
+
|
|
71
175
|
try {
|
|
72
|
-
const result = await pi.exec("bash", ["-c",
|
|
176
|
+
const result = await pi.exec("bash", ["-c", resolvedRun], { timeout: cmd.timeout * 1000, cwd: commandCwd });
|
|
73
177
|
results.push(
|
|
74
178
|
result.killed
|
|
75
179
|
? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
|
|
@@ -83,22 +187,99 @@ export async function runCommands(commands: CommandDef[], blockPatterns: string[
|
|
|
83
187
|
return results;
|
|
84
188
|
}
|
|
85
189
|
|
|
190
|
+
const SNAPSHOT_IGNORED_DIR_NAMES = new Set([
|
|
191
|
+
".git",
|
|
192
|
+
"node_modules",
|
|
193
|
+
".next",
|
|
194
|
+
".turbo",
|
|
195
|
+
".cache",
|
|
196
|
+
"coverage",
|
|
197
|
+
"dist",
|
|
198
|
+
"build",
|
|
199
|
+
".ralph-runner",
|
|
200
|
+
]);
|
|
201
|
+
const SNAPSHOT_MAX_FILES = 200;
|
|
202
|
+
const SNAPSHOT_MAX_BYTES = 2 * 1024 * 1024;
|
|
203
|
+
const SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS = 20;
|
|
204
|
+
const SNAPSHOT_POST_IDLE_POLL_WINDOW_MS = 100;
|
|
205
|
+
const RALPH_PROGRESS_FILE = "RALPH_PROGRESS.md";
|
|
206
|
+
|
|
207
|
+
type WorkspaceSnapshot = {
|
|
208
|
+
files: Map<string, string>;
|
|
209
|
+
truncated: boolean;
|
|
210
|
+
errorCount: number;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
type ProgressAssessment = {
|
|
214
|
+
progress: ProgressState;
|
|
215
|
+
changedFiles: string[];
|
|
216
|
+
snapshotTruncated: boolean;
|
|
217
|
+
snapshotErrorCount: number;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
type IterationCompletion = {
|
|
221
|
+
messages: PiAgentEndEvent["messages"];
|
|
222
|
+
observedTaskDirWrites: Set<string>;
|
|
223
|
+
error?: Error;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
type Deferred<T> = {
|
|
227
|
+
promise: Promise<T>;
|
|
228
|
+
resolve(value: T): void;
|
|
229
|
+
reject(reason?: unknown): void;
|
|
230
|
+
settled: boolean;
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
type PendingIterationState = {
|
|
234
|
+
prompt: string;
|
|
235
|
+
completion: Deferred<IterationCompletion>;
|
|
236
|
+
toolCallPaths: Map<string, string>;
|
|
237
|
+
observedTaskDirWrites: Set<string>;
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
function createDeferred<T>(): Deferred<T> {
|
|
241
|
+
let resolvePromise!: (value: T) => void;
|
|
242
|
+
let rejectPromise!: (reason?: unknown) => void;
|
|
243
|
+
const deferred: Deferred<T> = {
|
|
244
|
+
promise: new Promise<T>((resolve, reject) => {
|
|
245
|
+
resolvePromise = resolve;
|
|
246
|
+
rejectPromise = reject;
|
|
247
|
+
}),
|
|
248
|
+
resolve(value: T) {
|
|
249
|
+
if (deferred.settled) return;
|
|
250
|
+
deferred.settled = true;
|
|
251
|
+
resolvePromise(value);
|
|
252
|
+
},
|
|
253
|
+
reject(reason?: unknown) {
|
|
254
|
+
if (deferred.settled) return;
|
|
255
|
+
deferred.settled = true;
|
|
256
|
+
rejectPromise(reason);
|
|
257
|
+
},
|
|
258
|
+
settled: false,
|
|
259
|
+
};
|
|
260
|
+
return deferred;
|
|
261
|
+
}
|
|
262
|
+
|
|
86
263
|
function defaultLoopState(): LoopState {
|
|
87
264
|
return {
|
|
88
265
|
active: false,
|
|
89
266
|
ralphPath: "",
|
|
267
|
+
taskDir: "",
|
|
90
268
|
iteration: 0,
|
|
91
269
|
maxIterations: 50,
|
|
92
270
|
timeout: 300,
|
|
93
271
|
completionPromise: undefined,
|
|
94
272
|
stopRequested: false,
|
|
273
|
+
noProgressStreak: 0,
|
|
95
274
|
iterationSummaries: [],
|
|
96
275
|
guardrails: { blockCommands: [], protectedFiles: [] },
|
|
97
|
-
|
|
276
|
+
observedTaskDirWrites: new Set(),
|
|
277
|
+
loopToken: undefined,
|
|
278
|
+
cwd: "",
|
|
98
279
|
};
|
|
99
280
|
}
|
|
100
281
|
|
|
101
|
-
function readPersistedLoopState(ctx:
|
|
282
|
+
function readPersistedLoopState(ctx: Pick<CommandContext, "sessionManager">): PersistedLoopState | undefined {
|
|
102
283
|
const entries = ctx.sessionManager.getEntries();
|
|
103
284
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
104
285
|
const entry = entries[i];
|
|
@@ -113,6 +294,419 @@ function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
|
|
|
113
294
|
pi.appendEntry("ralph-loop-state", data);
|
|
114
295
|
}
|
|
115
296
|
|
|
297
|
+
function toPersistedLoopState(state: LoopState, overrides: Partial<PersistedLoopState> = {}): PersistedLoopState {
|
|
298
|
+
return {
|
|
299
|
+
active: state.active,
|
|
300
|
+
loopToken: state.loopToken,
|
|
301
|
+
cwd: state.cwd,
|
|
302
|
+
taskDir: state.taskDir,
|
|
303
|
+
iteration: state.iteration,
|
|
304
|
+
maxIterations: state.maxIterations,
|
|
305
|
+
noProgressStreak: state.noProgressStreak,
|
|
306
|
+
iterationSummaries: state.iterationSummaries,
|
|
307
|
+
guardrails: { blockCommands: state.guardrails.blockCommands, protectedFiles: state.guardrails.protectedFiles },
|
|
308
|
+
stopRequested: state.stopRequested,
|
|
309
|
+
...overrides,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function readActiveLoopState(ctx: Pick<CommandContext, "sessionManager">): ActiveLoopState | undefined {
|
|
314
|
+
const state = readPersistedLoopState(ctx);
|
|
315
|
+
if (state?.active !== true) return undefined;
|
|
316
|
+
if (typeof state.loopToken !== "string" || state.loopToken.length === 0) return undefined;
|
|
317
|
+
return state as ActiveLoopState;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function sanitizeStringArray(value: unknown): string[] {
|
|
321
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function sanitizeGuardrails(value: unknown): { blockCommands: string[]; protectedFiles: string[] } {
|
|
325
|
+
if (!value || typeof value !== "object") {
|
|
326
|
+
return { blockCommands: [], protectedFiles: [] };
|
|
327
|
+
}
|
|
328
|
+
const guardrails = value as { blockCommands?: unknown; protectedFiles?: unknown };
|
|
329
|
+
return {
|
|
330
|
+
blockCommands: sanitizeStringArray(guardrails.blockCommands),
|
|
331
|
+
protectedFiles: sanitizeStringArray(guardrails.protectedFiles),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function sanitizeProgressState(value: unknown): ProgressState {
|
|
336
|
+
return value === true || value === false || value === "unknown" ? value : "unknown";
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function sanitizeIterationSummary(record: unknown, loopToken: string): IterationSummary | undefined {
|
|
340
|
+
if (!record || typeof record !== "object") return undefined;
|
|
341
|
+
const iterationRecord = record as {
|
|
342
|
+
loopToken?: unknown;
|
|
343
|
+
iteration?: unknown;
|
|
344
|
+
durationMs?: unknown;
|
|
345
|
+
progress?: unknown;
|
|
346
|
+
changedFiles?: unknown;
|
|
347
|
+
noProgressStreak?: unknown;
|
|
348
|
+
snapshotTruncated?: unknown;
|
|
349
|
+
snapshotErrorCount?: unknown;
|
|
350
|
+
};
|
|
351
|
+
if (iterationRecord.loopToken !== loopToken) return undefined;
|
|
352
|
+
if (typeof iterationRecord.iteration !== "number" || !Number.isFinite(iterationRecord.iteration)) return undefined;
|
|
353
|
+
|
|
354
|
+
const durationMs = typeof iterationRecord.durationMs === "number" && Number.isFinite(iterationRecord.durationMs)
|
|
355
|
+
? iterationRecord.durationMs
|
|
356
|
+
: 0;
|
|
357
|
+
const noProgressStreak = typeof iterationRecord.noProgressStreak === "number" && Number.isFinite(iterationRecord.noProgressStreak)
|
|
358
|
+
? iterationRecord.noProgressStreak
|
|
359
|
+
: 0;
|
|
360
|
+
const snapshotErrorCount = typeof iterationRecord.snapshotErrorCount === "number" && Number.isFinite(iterationRecord.snapshotErrorCount)
|
|
361
|
+
? iterationRecord.snapshotErrorCount
|
|
362
|
+
: undefined;
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
iteration: iterationRecord.iteration,
|
|
366
|
+
duration: Math.round(durationMs / 1000),
|
|
367
|
+
progress: sanitizeProgressState(iterationRecord.progress),
|
|
368
|
+
changedFiles: sanitizeStringArray(iterationRecord.changedFiles),
|
|
369
|
+
noProgressStreak,
|
|
370
|
+
snapshotTruncated: typeof iterationRecord.snapshotTruncated === "boolean" ? iterationRecord.snapshotTruncated : undefined,
|
|
371
|
+
snapshotErrorCount,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseLoopContractInteger(raw: string | undefined): number | undefined {
|
|
376
|
+
if (typeof raw !== "string") return undefined;
|
|
377
|
+
const trimmed = raw.trim();
|
|
378
|
+
if (!/^-?\d+$/.test(trimmed)) return undefined;
|
|
379
|
+
const parsed = Number(trimmed);
|
|
380
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function parseLoopContractGuardrails(raw: string | undefined): { blockCommands: string[]; protectedFiles: string[] } | undefined {
|
|
384
|
+
if (typeof raw !== "string") return undefined;
|
|
385
|
+
try {
|
|
386
|
+
const parsed: unknown = JSON.parse(raw);
|
|
387
|
+
if (!parsed || typeof parsed !== "object") return undefined;
|
|
388
|
+
const guardrails = parsed as { blockCommands?: unknown; protectedFiles?: unknown };
|
|
389
|
+
if (
|
|
390
|
+
!Array.isArray(guardrails.blockCommands) ||
|
|
391
|
+
!guardrails.blockCommands.every((item) => typeof item === "string") ||
|
|
392
|
+
!Array.isArray(guardrails.protectedFiles) ||
|
|
393
|
+
!guardrails.protectedFiles.every((item) => typeof item === "string")
|
|
394
|
+
) {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
blockCommands: [...guardrails.blockCommands],
|
|
399
|
+
protectedFiles: [...guardrails.protectedFiles],
|
|
400
|
+
};
|
|
401
|
+
} catch {
|
|
402
|
+
return undefined;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function isStringArray(value: unknown): value is string[] {
|
|
407
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
|
411
|
+
return left.length === right.length && left.every((item, index) => item === right[index]);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function createFailClosedLoopState(taskDir: string, cwd?: string): ActiveLoopState {
|
|
415
|
+
return {
|
|
416
|
+
active: true,
|
|
417
|
+
loopToken: "",
|
|
418
|
+
cwd: cwd && cwd.length > 0 ? cwd : taskDir,
|
|
419
|
+
taskDir,
|
|
420
|
+
iteration: 0,
|
|
421
|
+
maxIterations: 0,
|
|
422
|
+
noProgressStreak: 0,
|
|
423
|
+
iterationSummaries: [],
|
|
424
|
+
guardrails: { blockCommands: [".*"], protectedFiles: ["**/*"] },
|
|
425
|
+
stopRequested: checkStopSignal(taskDir),
|
|
426
|
+
envMalformed: true,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function readEnvLoopState(taskDir: string): ActiveLoopState | undefined {
|
|
431
|
+
const cwd = process.env[RALPH_RUNNER_CWD_ENV]?.trim();
|
|
432
|
+
const loopToken = process.env[RALPH_RUNNER_LOOP_TOKEN_ENV]?.trim();
|
|
433
|
+
const currentIteration = parseLoopContractInteger(process.env[RALPH_RUNNER_CURRENT_ITERATION_ENV]);
|
|
434
|
+
const maxIterations = parseLoopContractInteger(process.env[RALPH_RUNNER_MAX_ITERATIONS_ENV]);
|
|
435
|
+
const noProgressStreak = parseLoopContractInteger(process.env[RALPH_RUNNER_NO_PROGRESS_STREAK_ENV]);
|
|
436
|
+
const guardrails = parseLoopContractGuardrails(process.env[RALPH_RUNNER_GUARDRAILS_ENV]);
|
|
437
|
+
|
|
438
|
+
if (
|
|
439
|
+
!cwd ||
|
|
440
|
+
!loopToken ||
|
|
441
|
+
currentIteration === undefined ||
|
|
442
|
+
currentIteration < 0 ||
|
|
443
|
+
maxIterations === undefined ||
|
|
444
|
+
maxIterations <= 0 ||
|
|
445
|
+
noProgressStreak === undefined ||
|
|
446
|
+
noProgressStreak < 0 ||
|
|
447
|
+
!guardrails
|
|
448
|
+
) {
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const iterationSummaries = readIterationRecords(taskDir)
|
|
453
|
+
.map((record) => sanitizeIterationSummary(record, loopToken))
|
|
454
|
+
.filter((summary): summary is IterationSummary => summary !== undefined);
|
|
455
|
+
|
|
456
|
+
return {
|
|
457
|
+
active: true,
|
|
458
|
+
loopToken,
|
|
459
|
+
cwd,
|
|
460
|
+
taskDir,
|
|
461
|
+
iteration: currentIteration,
|
|
462
|
+
maxIterations,
|
|
463
|
+
noProgressStreak,
|
|
464
|
+
iterationSummaries,
|
|
465
|
+
guardrails,
|
|
466
|
+
stopRequested: checkStopSignal(taskDir),
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function readDurableLoopState(taskDir: string, envState: ActiveLoopState): ActiveLoopState | undefined {
|
|
471
|
+
const envGuardrails = envState.guardrails;
|
|
472
|
+
if (!envGuardrails) return undefined;
|
|
473
|
+
|
|
474
|
+
const durableStatus = readStatusFile(taskDir);
|
|
475
|
+
if (!durableStatus || typeof durableStatus !== "object") return undefined;
|
|
476
|
+
|
|
477
|
+
const status = durableStatus as Record<string, unknown>;
|
|
478
|
+
const guardrails = status.guardrails as Record<string, unknown> | undefined;
|
|
479
|
+
if (
|
|
480
|
+
typeof status.loopToken !== "string" ||
|
|
481
|
+
status.loopToken.length === 0 ||
|
|
482
|
+
typeof status.cwd !== "string" ||
|
|
483
|
+
status.cwd.length === 0 ||
|
|
484
|
+
typeof status.currentIteration !== "number" ||
|
|
485
|
+
!Number.isInteger(status.currentIteration) ||
|
|
486
|
+
status.currentIteration < 0 ||
|
|
487
|
+
typeof status.maxIterations !== "number" ||
|
|
488
|
+
!Number.isInteger(status.maxIterations) ||
|
|
489
|
+
status.maxIterations <= 0 ||
|
|
490
|
+
typeof status.taskDir !== "string" ||
|
|
491
|
+
status.taskDir !== taskDir ||
|
|
492
|
+
!guardrails ||
|
|
493
|
+
!isStringArray(guardrails.blockCommands) ||
|
|
494
|
+
!isStringArray(guardrails.protectedFiles)
|
|
495
|
+
) {
|
|
496
|
+
return undefined;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const durableLoopToken = status.loopToken;
|
|
500
|
+
const durableCwd = status.cwd;
|
|
501
|
+
const durableGuardrails = guardrails as { blockCommands: string[]; protectedFiles: string[] };
|
|
502
|
+
|
|
503
|
+
if (
|
|
504
|
+
durableLoopToken !== envState.loopToken ||
|
|
505
|
+
durableCwd !== envState.cwd ||
|
|
506
|
+
status.currentIteration !== envState.iteration ||
|
|
507
|
+
status.maxIterations !== envState.maxIterations ||
|
|
508
|
+
!areStringArraysEqual(durableGuardrails.blockCommands, envGuardrails.blockCommands) ||
|
|
509
|
+
!areStringArraysEqual(durableGuardrails.protectedFiles, envGuardrails.protectedFiles)
|
|
510
|
+
) {
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
const iterationSummaries = readIterationRecords(taskDir)
|
|
515
|
+
.map((record) => sanitizeIterationSummary(record, durableLoopToken))
|
|
516
|
+
.filter((summary): summary is IterationSummary => summary !== undefined);
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
active: true,
|
|
520
|
+
loopToken: durableLoopToken,
|
|
521
|
+
cwd: durableCwd,
|
|
522
|
+
taskDir,
|
|
523
|
+
iteration: status.currentIteration,
|
|
524
|
+
maxIterations: status.maxIterations,
|
|
525
|
+
noProgressStreak: envState.noProgressStreak,
|
|
526
|
+
iterationSummaries,
|
|
527
|
+
guardrails: {
|
|
528
|
+
blockCommands: [...durableGuardrails.blockCommands],
|
|
529
|
+
protectedFiles: [...durableGuardrails.protectedFiles],
|
|
530
|
+
},
|
|
531
|
+
stopRequested: checkStopSignal(taskDir),
|
|
532
|
+
};
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function resolveActiveLoopState(ctx: Pick<CommandContext, "sessionManager">): ActiveLoopState | undefined {
|
|
536
|
+
const taskDir = process.env[RALPH_RUNNER_TASK_DIR_ENV]?.trim();
|
|
537
|
+
if (taskDir) {
|
|
538
|
+
const envState = readEnvLoopState(taskDir);
|
|
539
|
+
if (!envState) return createFailClosedLoopState(taskDir, process.env[RALPH_RUNNER_CWD_ENV]?.trim() || undefined);
|
|
540
|
+
return readDurableLoopState(taskDir, envState) ?? createFailClosedLoopState(taskDir, envState.cwd);
|
|
541
|
+
}
|
|
542
|
+
return readActiveLoopState(ctx);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function resolveActiveIterationState(ctx: Pick<CommandContext, "sessionManager">): ActiveIterationState | undefined {
|
|
546
|
+
const state = resolveActiveLoopState(ctx);
|
|
547
|
+
if (!state || typeof state.iteration !== "number") return undefined;
|
|
548
|
+
return state as ActiveIterationState;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function getLoopIterationKey(loopToken: string, iteration: number): string {
|
|
552
|
+
return `${loopToken}:${iteration}`;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function normalizeSnapshotPath(filePath: string): string {
|
|
556
|
+
return filePath.split("\\").join("/");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function captureTaskDirectorySnapshot(ralphPath: string): WorkspaceSnapshot {
|
|
560
|
+
const taskDir = dirname(ralphPath);
|
|
561
|
+
const progressMemoryPath = join(taskDir, RALPH_PROGRESS_FILE);
|
|
562
|
+
const files = new Map<string, string>();
|
|
563
|
+
let truncated = false;
|
|
564
|
+
let bytesRead = 0;
|
|
565
|
+
let errorCount = 0;
|
|
566
|
+
|
|
567
|
+
const walk = (dirPath: string) => {
|
|
568
|
+
let entries;
|
|
569
|
+
try {
|
|
570
|
+
entries = readdirSync(dirPath, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name));
|
|
571
|
+
} catch {
|
|
572
|
+
errorCount += 1;
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
for (const entry of entries) {
|
|
577
|
+
if (truncated) return;
|
|
578
|
+
const fullPath = join(dirPath, entry.name);
|
|
579
|
+
|
|
580
|
+
if (entry.isDirectory()) {
|
|
581
|
+
if (SNAPSHOT_IGNORED_DIR_NAMES.has(entry.name)) continue;
|
|
582
|
+
walk(fullPath);
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
if (!entry.isFile() || fullPath === ralphPath || fullPath === progressMemoryPath) continue;
|
|
586
|
+
if (files.size >= SNAPSHOT_MAX_FILES) {
|
|
587
|
+
truncated = true;
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const relPath = normalizeSnapshotPath(relative(taskDir, fullPath));
|
|
592
|
+
if (!relPath || relPath.startsWith("..")) continue;
|
|
593
|
+
|
|
594
|
+
let content;
|
|
595
|
+
try {
|
|
596
|
+
content = readFileSync(fullPath);
|
|
597
|
+
} catch {
|
|
598
|
+
errorCount += 1;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
if (bytesRead + content.byteLength > SNAPSHOT_MAX_BYTES) {
|
|
602
|
+
truncated = true;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
bytesRead += content.byteLength;
|
|
607
|
+
files.set(relPath, `${content.byteLength}:${createHash("sha1").update(content).digest("hex")}`);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
|
|
611
|
+
if (existsSync(taskDir)) walk(taskDir);
|
|
612
|
+
return { files, truncated, errorCount };
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function diffTaskDirectorySnapshots(before: WorkspaceSnapshot, after: WorkspaceSnapshot): string[] {
|
|
616
|
+
const changed = new Set<string>();
|
|
617
|
+
for (const [filePath, fingerprint] of before.files) {
|
|
618
|
+
if (after.files.get(filePath) !== fingerprint) changed.add(filePath);
|
|
619
|
+
}
|
|
620
|
+
for (const filePath of after.files.keys()) {
|
|
621
|
+
if (!before.files.has(filePath)) changed.add(filePath);
|
|
622
|
+
}
|
|
623
|
+
return [...changed].sort((a, b) => a.localeCompare(b));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function resolveTaskDirObservedPath(taskDir: string, cwd: string, filePath: string): string | undefined {
|
|
627
|
+
if (!taskDir || !cwd || !filePath) return undefined;
|
|
628
|
+
const relPath = normalizeSnapshotPath(relative(resolve(taskDir), resolve(cwd, filePath)));
|
|
629
|
+
if (!relPath || relPath === "." || relPath.startsWith("..")) return undefined;
|
|
630
|
+
return relPath;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function delay(ms: number): Promise<void> {
|
|
634
|
+
return new Promise((resolveDelay) => {
|
|
635
|
+
setTimeout(resolveDelay, ms);
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function assessTaskDirectoryProgress(
|
|
640
|
+
ralphPath: string,
|
|
641
|
+
before: WorkspaceSnapshot,
|
|
642
|
+
observedTaskDirWrites: ReadonlySet<string>,
|
|
643
|
+
): Promise<ProgressAssessment> {
|
|
644
|
+
let after = captureTaskDirectorySnapshot(ralphPath);
|
|
645
|
+
let changedFiles = diffTaskDirectorySnapshots(before, after);
|
|
646
|
+
let snapshotTruncated = before.truncated || after.truncated;
|
|
647
|
+
let snapshotErrorCount = before.errorCount + after.errorCount;
|
|
648
|
+
|
|
649
|
+
if (changedFiles.length > 0) {
|
|
650
|
+
return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
for (let remainingMs = SNAPSHOT_POST_IDLE_POLL_WINDOW_MS; remainingMs > 0; remainingMs -= SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS) {
|
|
654
|
+
await delay(Math.min(SNAPSHOT_POST_IDLE_POLL_INTERVAL_MS, remainingMs));
|
|
655
|
+
after = captureTaskDirectorySnapshot(ralphPath);
|
|
656
|
+
changedFiles = diffTaskDirectorySnapshots(before, after);
|
|
657
|
+
snapshotTruncated ||= after.truncated;
|
|
658
|
+
snapshotErrorCount += after.errorCount;
|
|
659
|
+
if (changedFiles.length > 0) {
|
|
660
|
+
return { progress: true, changedFiles, snapshotTruncated, snapshotErrorCount };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (observedTaskDirWrites.size > 0) {
|
|
665
|
+
return { progress: "unknown", changedFiles: [], snapshotTruncated, snapshotErrorCount };
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
return {
|
|
669
|
+
progress: snapshotTruncated || snapshotErrorCount > 0 ? "unknown" : false,
|
|
670
|
+
changedFiles,
|
|
671
|
+
snapshotTruncated,
|
|
672
|
+
snapshotErrorCount,
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function summarizeChangedFiles(changedFiles: string[]): string {
|
|
677
|
+
if (changedFiles.length === 0) return "none";
|
|
678
|
+
const visible = changedFiles.slice(0, 5);
|
|
679
|
+
if (visible.length === changedFiles.length) return visible.join(", ");
|
|
680
|
+
return `${visible.join(", ")} (+${changedFiles.length - visible.length} more)`;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function summarizeSnapshotCoverage(truncated: boolean, errorCount: number): string {
|
|
684
|
+
const parts: string[] = [];
|
|
685
|
+
if (truncated) parts.push("snapshot truncated");
|
|
686
|
+
if (errorCount > 0) parts.push(errorCount === 1 ? "1 file unreadable" : `${errorCount} files unreadable`);
|
|
687
|
+
return parts.join(", ");
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function summarizeIterationProgress(summary: Pick<IterationSummary, "progress" | "changedFiles" | "snapshotTruncated" | "snapshotErrorCount">): string {
|
|
691
|
+
if (summary.progress === true) return `durable progress (${summarizeChangedFiles(summary.changedFiles)})`;
|
|
692
|
+
if (summary.progress === false) return "no durable progress";
|
|
693
|
+
const coverage = summarizeSnapshotCoverage(summary.snapshotTruncated ?? false, summary.snapshotErrorCount ?? 0);
|
|
694
|
+
return coverage ? `durable progress unknown (${coverage})` : "durable progress unknown";
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function summarizeLastIterationFeedback(summary: IterationSummary | undefined, fallbackNoProgressStreak: number): string {
|
|
698
|
+
if (!summary) return "";
|
|
699
|
+
if (summary.progress === true) {
|
|
700
|
+
return `Last iteration durable progress: ${summarizeChangedFiles(summary.changedFiles)}.`;
|
|
701
|
+
}
|
|
702
|
+
if (summary.progress === false) {
|
|
703
|
+
return `Last iteration made no durable progress. No-progress streak: ${summary.noProgressStreak ?? fallbackNoProgressStreak}.`;
|
|
704
|
+
}
|
|
705
|
+
const coverage = summarizeSnapshotCoverage(summary.snapshotTruncated ?? false, summary.snapshotErrorCount ?? 0);
|
|
706
|
+
const detail = coverage ? ` (${coverage})` : "";
|
|
707
|
+
return `Last iteration durable progress could not be verified${detail}. No-progress streak remains ${summary.noProgressStreak ?? fallbackNoProgressStreak}.`;
|
|
708
|
+
}
|
|
709
|
+
|
|
116
710
|
function writeDraftFile(ralphPath: string, content: string) {
|
|
117
711
|
mkdirSync(dirname(ralphPath), { recursive: true });
|
|
118
712
|
writeFileSync(ralphPath, content, "utf8");
|
|
@@ -123,14 +717,14 @@ function displayPath(cwd: string, filePath: string): string {
|
|
|
123
717
|
return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
|
|
124
718
|
}
|
|
125
719
|
|
|
126
|
-
async function promptForTask(ctx:
|
|
720
|
+
async function promptForTask(ctx: Pick<CommandContext, "hasUI" | "ui">, title: string, placeholder: string): Promise<string | undefined> {
|
|
127
721
|
if (!ctx.hasUI) return undefined;
|
|
128
722
|
const value = await ctx.ui.input(title, placeholder);
|
|
129
723
|
const trimmed = value?.trim();
|
|
130
724
|
return trimmed ? trimmed : undefined;
|
|
131
725
|
}
|
|
132
726
|
|
|
133
|
-
async function reviewDraft(plan:
|
|
727
|
+
async function reviewDraft(plan: DraftPlan, mode: "run" | "draft", ctx: Pick<CommandContext, "ui">): Promise<{ action: "start" | "save" | "cancel"; content: string }> {
|
|
134
728
|
let content = plan.content;
|
|
135
729
|
|
|
136
730
|
while (true) {
|
|
@@ -162,7 +756,7 @@ async function reviewDraft(plan: ReturnType<typeof generateDraft>, mode: "run" |
|
|
|
162
756
|
}
|
|
163
757
|
}
|
|
164
758
|
|
|
165
|
-
async function editExistingDraft(ralphPath: string, ctx:
|
|
759
|
+
async function editExistingDraft(ralphPath: string, ctx: Pick<CommandContext, "cwd" | "hasUI" | "ui">, saveMessage = "Saved RALPH.md") {
|
|
166
760
|
if (!ctx.hasUI) {
|
|
167
761
|
ctx.ui.notify(`Use ${displayPath(ctx.cwd, ralphPath)} in an interactive session to edit the draft.`, "warning");
|
|
168
762
|
return;
|
|
@@ -194,7 +788,7 @@ async function editExistingDraft(ralphPath: string, ctx: any, saveMessage = "Sav
|
|
|
194
788
|
async function chooseRecoveryMode(
|
|
195
789
|
input: string,
|
|
196
790
|
dirPath: string,
|
|
197
|
-
ctx:
|
|
791
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
198
792
|
allowTaskFallback = true,
|
|
199
793
|
): Promise<"draft-path" | "task" | "cancel"> {
|
|
200
794
|
const options = allowTaskFallback ? ["Draft in that folder", "Treat as task text", "Cancel"] : ["Draft in that folder", "Cancel"];
|
|
@@ -204,7 +798,7 @@ async function chooseRecoveryMode(
|
|
|
204
798
|
return "cancel";
|
|
205
799
|
}
|
|
206
800
|
|
|
207
|
-
async function chooseConflictTarget(commandName: "ralph" | "ralph-draft", task: string, target: DraftTarget, ctx:
|
|
801
|
+
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
802
|
const hasExistingDraft = existsSync(target.ralphPath);
|
|
209
803
|
const title = hasExistingDraft
|
|
210
804
|
? `Found an existing RALPH at ${displayPath(ctx.cwd, target.ralphPath)} for “${task}”.`
|
|
@@ -225,8 +819,23 @@ async function chooseConflictTarget(commandName: "ralph" | "ralph-draft", task:
|
|
|
225
819
|
return { action: "draft-target", target: createSiblingTarget(ctx.cwd, target.slug) };
|
|
226
820
|
}
|
|
227
821
|
|
|
228
|
-
|
|
229
|
-
|
|
822
|
+
function getDraftStrengtheningRuntime(ctx: Pick<CommandContext, "model" | "modelRegistry">): StrengthenDraftRuntime | undefined {
|
|
823
|
+
if (!ctx.model || !ctx.modelRegistry) return undefined;
|
|
824
|
+
return {
|
|
825
|
+
model: ctx.model,
|
|
826
|
+
modelRegistry: ctx.modelRegistry,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
async function draftFromTask(
|
|
831
|
+
commandName: "ralph" | "ralph-draft",
|
|
832
|
+
task: string,
|
|
833
|
+
target: DraftTarget,
|
|
834
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
835
|
+
draftPlanFactory: DraftPlanFactory,
|
|
836
|
+
runtime?: StrengthenDraftRuntime,
|
|
837
|
+
): Promise<string | undefined> {
|
|
838
|
+
const plan = await draftPlanFactory(task, target, ctx.cwd, runtime);
|
|
230
839
|
const review = await reviewDraft(plan, commandName === "ralph" ? "run" : "draft", ctx);
|
|
231
840
|
if (review.action === "cancel") return undefined;
|
|
232
841
|
|
|
@@ -238,41 +847,223 @@ async function draftFromTask(commandName: "ralph" | "ralph-draft", task: string,
|
|
|
238
847
|
return target.ralphPath;
|
|
239
848
|
}
|
|
240
849
|
|
|
850
|
+
function resolveSessionStopTarget(ctx: Pick<CommandContext, "cwd" | "sessionManager">, now: string): {
|
|
851
|
+
target?: StopTarget;
|
|
852
|
+
persistedSessionState?: ActiveLoopState;
|
|
853
|
+
} {
|
|
854
|
+
if (loopState.active) {
|
|
855
|
+
return {
|
|
856
|
+
target: {
|
|
857
|
+
cwd: loopState.cwd || ctx.cwd,
|
|
858
|
+
taskDir: loopState.taskDir,
|
|
859
|
+
ralphPath: loopState.ralphPath,
|
|
860
|
+
loopToken: loopState.loopToken ?? "",
|
|
861
|
+
currentIteration: loopState.iteration,
|
|
862
|
+
maxIterations: loopState.maxIterations,
|
|
863
|
+
startedAt: now,
|
|
864
|
+
source: "session",
|
|
865
|
+
},
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
const persistedSessionState = readActiveLoopState(ctx);
|
|
870
|
+
if (
|
|
871
|
+
!persistedSessionState ||
|
|
872
|
+
typeof persistedSessionState.taskDir !== "string" ||
|
|
873
|
+
persistedSessionState.taskDir.length === 0 ||
|
|
874
|
+
typeof persistedSessionState.loopToken !== "string" ||
|
|
875
|
+
persistedSessionState.loopToken.length === 0 ||
|
|
876
|
+
typeof persistedSessionState.iteration !== "number" ||
|
|
877
|
+
typeof persistedSessionState.maxIterations !== "number"
|
|
878
|
+
) {
|
|
879
|
+
return { persistedSessionState };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
return {
|
|
883
|
+
persistedSessionState,
|
|
884
|
+
target: {
|
|
885
|
+
cwd: typeof persistedSessionState.cwd === "string" && persistedSessionState.cwd.length > 0 ? persistedSessionState.cwd : ctx.cwd,
|
|
886
|
+
taskDir: persistedSessionState.taskDir,
|
|
887
|
+
ralphPath: join(persistedSessionState.taskDir, "RALPH.md"),
|
|
888
|
+
loopToken: persistedSessionState.loopToken,
|
|
889
|
+
currentIteration: persistedSessionState.iteration,
|
|
890
|
+
maxIterations: persistedSessionState.maxIterations,
|
|
891
|
+
startedAt: now,
|
|
892
|
+
source: "session",
|
|
893
|
+
},
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function materializeRegistryStopTarget(entry: ActiveLoopRegistryEntry): StopTarget {
|
|
898
|
+
return {
|
|
899
|
+
cwd: entry.cwd,
|
|
900
|
+
taskDir: entry.taskDir,
|
|
901
|
+
ralphPath: entry.ralphPath,
|
|
902
|
+
loopToken: entry.loopToken,
|
|
903
|
+
currentIteration: entry.currentIteration,
|
|
904
|
+
maxIterations: entry.maxIterations,
|
|
905
|
+
startedAt: entry.startedAt,
|
|
906
|
+
source: "registry",
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function applyStopTarget(
|
|
911
|
+
pi: ExtensionAPI,
|
|
912
|
+
ctx: Pick<CommandContext, "cwd" | "ui">,
|
|
913
|
+
target: StopTarget,
|
|
914
|
+
now: string,
|
|
915
|
+
persistedSessionState?: ActiveLoopState,
|
|
916
|
+
): void {
|
|
917
|
+
createStopSignal(target.taskDir);
|
|
918
|
+
|
|
919
|
+
const registryCwd = target.cwd;
|
|
920
|
+
const existingEntry = readActiveLoopRegistry(registryCwd).find((entry) => entry.taskDir === target.taskDir);
|
|
921
|
+
const registryEntry: ActiveLoopRegistryEntry = existingEntry
|
|
922
|
+
? {
|
|
923
|
+
...existingEntry,
|
|
924
|
+
taskDir: target.taskDir,
|
|
925
|
+
ralphPath: target.ralphPath,
|
|
926
|
+
cwd: registryCwd,
|
|
927
|
+
updatedAt: now,
|
|
928
|
+
}
|
|
929
|
+
: {
|
|
930
|
+
taskDir: target.taskDir,
|
|
931
|
+
ralphPath: target.ralphPath,
|
|
932
|
+
cwd: registryCwd,
|
|
933
|
+
loopToken: target.loopToken,
|
|
934
|
+
status: "running",
|
|
935
|
+
currentIteration: target.currentIteration,
|
|
936
|
+
maxIterations: target.maxIterations,
|
|
937
|
+
startedAt: target.startedAt,
|
|
938
|
+
updatedAt: now,
|
|
939
|
+
};
|
|
940
|
+
writeActiveLoopRegistryEntry(registryCwd, registryEntry);
|
|
941
|
+
recordActiveLoopStopRequest(registryCwd, target.taskDir, now);
|
|
942
|
+
|
|
943
|
+
if (target.source === "session") {
|
|
944
|
+
loopState.stopRequested = true;
|
|
945
|
+
if (loopState.active) {
|
|
946
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: true, stopRequested: true }));
|
|
947
|
+
} else if (persistedSessionState?.active) {
|
|
948
|
+
persistLoopState(pi, { ...persistedSessionState, stopRequested: true });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
953
|
+
}
|
|
954
|
+
|
|
241
955
|
let loopState: LoopState = defaultLoopState();
|
|
956
|
+
const RALPH_EXTENSION_REGISTERED = Symbol.for("pi-ralph-loop.registered");
|
|
242
957
|
|
|
243
|
-
export default function (pi: ExtensionAPI) {
|
|
958
|
+
export default function (pi: ExtensionAPI, services: RegisterRalphCommandServices = {}) {
|
|
959
|
+
const registeredPi = pi as ExtensionAPI & Record<symbol, boolean | undefined>;
|
|
960
|
+
if (registeredPi[RALPH_EXTENSION_REGISTERED]) return;
|
|
961
|
+
registeredPi[RALPH_EXTENSION_REGISTERED] = true;
|
|
244
962
|
const failCounts = new Map<string, number>();
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
963
|
+
const pendingIterations = new Map<string, PendingIterationState>();
|
|
964
|
+
const draftPlanFactory = services.createDraftPlan ?? createDraftPlanService;
|
|
965
|
+
const isLoopSession = (ctx: Pick<CommandContext, "sessionManager">): boolean => resolveActiveLoopState(ctx) !== undefined;
|
|
966
|
+
const appendLoopProofEntry = (customType: string, data: Record<string, unknown>): void => {
|
|
967
|
+
try {
|
|
968
|
+
pi.appendEntry?.(customType, data);
|
|
969
|
+
} catch (err) {
|
|
970
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
971
|
+
try {
|
|
972
|
+
process.stderr.write(`Ralph proof logging failed for ${customType}: ${message}\n`);
|
|
973
|
+
} catch {
|
|
974
|
+
// Best-effort surfacing only.
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
const getPendingIteration = (ctx: Pick<CommandContext, "sessionManager">): PendingIterationState | undefined => {
|
|
979
|
+
const state = resolveActiveIterationState(ctx);
|
|
980
|
+
return state ? pendingIterations.get(getLoopIterationKey(state.loopToken, state.iteration)) : undefined;
|
|
981
|
+
};
|
|
982
|
+
const registerPendingIteration = (loopToken: string, iteration: number, prompt: string): PendingIterationState => {
|
|
983
|
+
const pending: PendingIterationState = {
|
|
984
|
+
prompt,
|
|
985
|
+
completion: createDeferred<IterationCompletion>(),
|
|
986
|
+
toolCallPaths: new Map(),
|
|
987
|
+
observedTaskDirWrites: new Set(),
|
|
988
|
+
};
|
|
989
|
+
pendingIterations.set(getLoopIterationKey(loopToken, iteration), pending);
|
|
990
|
+
return pending;
|
|
991
|
+
};
|
|
992
|
+
const clearPendingIteration = (loopToken: string, iteration: number) => {
|
|
993
|
+
pendingIterations.delete(getLoopIterationKey(loopToken, iteration));
|
|
994
|
+
};
|
|
995
|
+
const resolvePendingIteration = (ctx: EventContext, event: AgentEndEvent) => {
|
|
996
|
+
const state = resolveActiveIterationState(ctx);
|
|
997
|
+
if (!state) return;
|
|
998
|
+
const pendingKey = getLoopIterationKey(state.loopToken, state.iteration);
|
|
999
|
+
const pending = pendingIterations.get(pendingKey);
|
|
1000
|
+
if (!pending) return;
|
|
1001
|
+
pendingIterations.delete(pendingKey);
|
|
1002
|
+
const rawError = (event as { error?: unknown }).error;
|
|
1003
|
+
const error = rawError instanceof Error ? rawError : rawError ? new Error(String(rawError)) : undefined;
|
|
1004
|
+
pending.completion.resolve({
|
|
1005
|
+
messages: event.messages ?? [],
|
|
1006
|
+
observedTaskDirWrites: new Set(pending.observedTaskDirWrites),
|
|
1007
|
+
error,
|
|
1008
|
+
});
|
|
1009
|
+
};
|
|
1010
|
+
const recordPendingToolPath = (ctx: EventContext, event: ToolEvent) => {
|
|
1011
|
+
const pending = getPendingIteration(ctx);
|
|
1012
|
+
if (!pending) return;
|
|
1013
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
1014
|
+
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
1015
|
+
const filePath = event.input?.path ?? "";
|
|
1016
|
+
if (toolCallId && filePath) pending.toolCallPaths.set(toolCallId, filePath);
|
|
1017
|
+
};
|
|
1018
|
+
const recordSuccessfulTaskDirWrite = (ctx: EventContext, event: ToolEvent) => {
|
|
1019
|
+
const pending = getPendingIteration(ctx);
|
|
1020
|
+
if (!pending) return;
|
|
1021
|
+
if (event.toolName !== "write" && event.toolName !== "edit") return;
|
|
1022
|
+
const toolCallId = typeof event.toolCallId === "string" ? event.toolCallId : undefined;
|
|
1023
|
+
const filePath = toolCallId ? pending.toolCallPaths.get(toolCallId) : undefined;
|
|
1024
|
+
if (toolCallId) pending.toolCallPaths.delete(toolCallId);
|
|
1025
|
+
if (event.isError === true || event.success === false || !filePath) return;
|
|
1026
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1027
|
+
const taskDirPath = persisted?.taskDir ?? loopState.taskDir;
|
|
1028
|
+
const cwd = persisted?.cwd ?? loopState.cwd;
|
|
1029
|
+
const relPath = resolveTaskDirObservedPath(taskDirPath ?? "", cwd ?? taskDirPath ?? "", filePath);
|
|
1030
|
+
if (relPath && relPath !== RALPH_PROGRESS_FILE) pending.observedTaskDirWrites.add(relPath);
|
|
249
1031
|
};
|
|
250
1032
|
|
|
251
|
-
async function startRalphLoop(ralphPath: string, ctx:
|
|
1033
|
+
async function startRalphLoop(ralphPath: string, ctx: CommandContext, runLoopFn: typeof runRalphLoop = runRalphLoop, runtimeArgs: RuntimeArgs = {}) {
|
|
252
1034
|
let name: string;
|
|
253
1035
|
try {
|
|
254
1036
|
const raw = readFileSync(ralphPath, "utf8");
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
return;
|
|
260
|
-
}
|
|
1037
|
+
const draftError = validateDraftContent(raw);
|
|
1038
|
+
if (draftError) {
|
|
1039
|
+
ctx.ui.notify(`Invalid RALPH.md: ${draftError}`, "error");
|
|
1040
|
+
return;
|
|
261
1041
|
}
|
|
262
|
-
const
|
|
1042
|
+
const parsed = parseRalphMarkdown(raw);
|
|
1043
|
+
const { frontmatter } = parsed;
|
|
263
1044
|
if (!validateFrontmatter(frontmatter, ctx)) return;
|
|
264
|
-
|
|
1045
|
+
const runtimeValidationError = validateRuntimeArgs(frontmatter, parsed.body, frontmatter.commands, runtimeArgs);
|
|
1046
|
+
if (runtimeValidationError) {
|
|
1047
|
+
ctx.ui.notify(runtimeValidationError, "error");
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
const taskDir = dirname(ralphPath);
|
|
1051
|
+
name = basename(taskDir);
|
|
265
1052
|
loopState = {
|
|
266
1053
|
active: true,
|
|
267
1054
|
ralphPath,
|
|
1055
|
+
taskDir,
|
|
1056
|
+
cwd: ctx.cwd,
|
|
268
1057
|
iteration: 0,
|
|
269
1058
|
maxIterations: frontmatter.maxIterations,
|
|
270
1059
|
timeout: frontmatter.timeout,
|
|
271
1060
|
completionPromise: frontmatter.completionPromise,
|
|
272
1061
|
stopRequested: false,
|
|
1062
|
+
noProgressStreak: 0,
|
|
273
1063
|
iterationSummaries: [],
|
|
274
1064
|
guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
|
|
275
|
-
|
|
1065
|
+
observedTaskDirWrites: new Set(),
|
|
1066
|
+
loopToken: randomUUID(),
|
|
276
1067
|
};
|
|
277
1068
|
} catch (err) {
|
|
278
1069
|
ctx.ui.notify(String(err), "error");
|
|
@@ -281,143 +1072,118 @@ export default function (pi: ExtensionAPI) {
|
|
|
281
1072
|
ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
|
|
282
1073
|
|
|
283
1074
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
1075
|
+
const result = await runLoopFn({
|
|
1076
|
+
ralphPath,
|
|
1077
|
+
cwd: ctx.cwd,
|
|
1078
|
+
timeout: loopState.timeout,
|
|
1079
|
+
maxIterations: loopState.maxIterations,
|
|
1080
|
+
guardrails: loopState.guardrails,
|
|
1081
|
+
runtimeArgs,
|
|
1082
|
+
modelPattern: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
|
|
1083
|
+
thinkingLevel: ctx.model?.reasoning ? "high" : undefined,
|
|
1084
|
+
runCommandsFn: async (commands, blocked, commandPi, cwd, taskDir) => runCommands(commands, blocked, commandPi as ExtensionAPI, runtimeArgs, cwd, taskDir),
|
|
1085
|
+
onStatusChange(status) {
|
|
1086
|
+
ctx.ui.setStatus("ralph", status === "running" || status === "initializing" ? `🔁 ${name}: running` : undefined);
|
|
1087
|
+
},
|
|
1088
|
+
onNotify(message, level) {
|
|
1089
|
+
ctx.ui.notify(message, level);
|
|
1090
|
+
},
|
|
1091
|
+
onIterationComplete(record) {
|
|
1092
|
+
loopState.iteration = record.iteration;
|
|
1093
|
+
loopState.noProgressStreak = record.noProgressStreak;
|
|
1094
|
+
const summary: IterationSummary = {
|
|
1095
|
+
iteration: record.iteration,
|
|
1096
|
+
duration: record.durationMs ? Math.round(record.durationMs / 1000) : 0,
|
|
1097
|
+
progress: record.progress,
|
|
1098
|
+
changedFiles: record.changedFiles,
|
|
1099
|
+
noProgressStreak: record.noProgressStreak,
|
|
1100
|
+
};
|
|
1101
|
+
loopState.iterationSummaries.push(summary);
|
|
1102
|
+
pi.appendEntry("ralph-iteration", {
|
|
1103
|
+
iteration: record.iteration,
|
|
1104
|
+
duration: summary.duration,
|
|
1105
|
+
ralphPath: loopState.ralphPath,
|
|
1106
|
+
progress: record.progress,
|
|
1107
|
+
changedFiles: record.changedFiles,
|
|
1108
|
+
noProgressStreak: record.noProgressStreak,
|
|
1109
|
+
});
|
|
1110
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: true, stopRequested: false }));
|
|
1111
|
+
},
|
|
1112
|
+
pi,
|
|
1113
|
+
});
|
|
292
1114
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
ctx.ui.notify(`
|
|
1115
|
+
// Map runner result to UI notifications
|
|
1116
|
+
const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
|
|
1117
|
+
switch (result.status) {
|
|
1118
|
+
case "complete":
|
|
1119
|
+
ctx.ui.notify(`Ralph loop complete: completion promise matched on iteration ${result.iterations.length} (${total}s total)`, "info");
|
|
298
1120
|
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");
|
|
1121
|
+
case "max-iterations":
|
|
1122
|
+
ctx.ui.notify(`Ralph loop reached max iterations: ${result.iterations.length} iterations, ${total}s total`, "info");
|
|
319
1123
|
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");
|
|
1124
|
+
case "no-progress-exhaustion":
|
|
1125
|
+
ctx.ui.notify(`Ralph loop exhausted without verified progress: ${result.iterations.length} iterations, ${total}s total`, "warning");
|
|
361
1126
|
break;
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
ctx.ui.notify(`Iteration ${i} agent error: ${idleError!.message}, stopping loop`, "error");
|
|
1127
|
+
case "stopped":
|
|
1128
|
+
ctx.ui.notify(`Ralph loop stopped: ${result.iterations.length} iterations, ${total}s total`, "info");
|
|
365
1129
|
break;
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
loopState.stopRequested = true;
|
|
375
|
-
ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
|
|
1130
|
+
case "timeout":
|
|
1131
|
+
ctx.ui.notify(`Ralph loop stopped after a timeout: ${result.iterations.length} iterations, ${total}s total`, "warning");
|
|
1132
|
+
break;
|
|
1133
|
+
case "error":
|
|
1134
|
+
ctx.ui.notify(`Ralph loop failed: ${result.iterations.length} iterations, ${total}s total`, "error");
|
|
1135
|
+
break;
|
|
1136
|
+
default:
|
|
1137
|
+
ctx.ui.notify(`Ralph loop ended: ${result.status} (${total}s total)`, "info");
|
|
376
1138
|
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
1139
|
}
|
|
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
1140
|
} catch (err) {
|
|
398
1141
|
const message = err instanceof Error ? err.message : String(err);
|
|
399
1142
|
ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
|
|
400
1143
|
} finally {
|
|
401
1144
|
failCounts.clear();
|
|
1145
|
+
pendingIterations.clear();
|
|
402
1146
|
loopState.active = false;
|
|
403
1147
|
loopState.stopRequested = false;
|
|
404
|
-
loopState.
|
|
1148
|
+
loopState.loopToken = undefined;
|
|
405
1149
|
ctx.ui.setStatus("ralph", undefined);
|
|
406
|
-
persistLoopState(pi, { active: false });
|
|
1150
|
+
persistLoopState(pi, toPersistedLoopState(loopState, { active: false, stopRequested: false }));
|
|
407
1151
|
}
|
|
408
1152
|
}
|
|
409
1153
|
|
|
410
|
-
|
|
1154
|
+
let runtimeArgsForStart: RuntimeArgs = {};
|
|
1155
|
+
|
|
1156
|
+
async function handleDraftCommand(commandName: "ralph" | "ralph-draft", args: string, ctx: CommandContext): Promise<string | undefined> {
|
|
411
1157
|
const parsed = parseCommandArgs(args);
|
|
1158
|
+
if (parsed.error) {
|
|
1159
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1160
|
+
return undefined;
|
|
1161
|
+
}
|
|
1162
|
+
const runtimeArgsResult = runtimeArgEntriesToMap(parsed.runtimeArgs);
|
|
1163
|
+
if (runtimeArgsResult.error) {
|
|
1164
|
+
ctx.ui.notify(runtimeArgsResult.error, "error");
|
|
1165
|
+
return undefined;
|
|
1166
|
+
}
|
|
1167
|
+
const runtimeArgs = runtimeArgsResult.runtimeArgs;
|
|
1168
|
+
if (parsed.runtimeArgs.length > 0 && (commandName === "ralph-draft" || parsed.mode !== "path")) {
|
|
1169
|
+
ctx.ui.notify("--arg is only supported with /ralph --path", "error");
|
|
1170
|
+
return undefined;
|
|
1171
|
+
}
|
|
1172
|
+
runtimeArgsForStart = runtimeArgs;
|
|
1173
|
+
const draftRuntime = getDraftStrengtheningRuntime(ctx);
|
|
412
1174
|
|
|
413
1175
|
const resolveTaskForFolder = async (target: DraftTarget): Promise<string | undefined> => {
|
|
414
1176
|
const task = await promptForTask(ctx, "What should Ralph work on in this folder?", "reverse engineer this app");
|
|
415
1177
|
if (!task) return undefined;
|
|
416
|
-
return draftFromTask(commandName, task, target, ctx);
|
|
1178
|
+
return draftFromTask(commandName, task, target, ctx, draftPlanFactory, draftRuntime);
|
|
417
1179
|
};
|
|
418
1180
|
|
|
419
|
-
const handleExistingInspection = async (input: string, explicitPath = false): Promise<string | undefined> => {
|
|
1181
|
+
const handleExistingInspection = async (input: string, explicitPath = false, runtimeArgsProvided = false): Promise<string | undefined> => {
|
|
420
1182
|
const inspection = inspectExistingTarget(input, ctx.cwd, explicitPath);
|
|
1183
|
+
if (runtimeArgsProvided && inspection.kind !== "run") {
|
|
1184
|
+
ctx.ui.notify("--arg is only supported with /ralph --path to an existing RALPH.md", "error");
|
|
1185
|
+
return undefined;
|
|
1186
|
+
}
|
|
421
1187
|
switch (inspection.kind) {
|
|
422
1188
|
case "run":
|
|
423
1189
|
if (commandName === "ralph") return inspection.ralphPath;
|
|
@@ -466,14 +1232,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
466
1232
|
}
|
|
467
1233
|
planned = { kind: "draft", target: decision.target! };
|
|
468
1234
|
}
|
|
469
|
-
return draftFromTask(commandName, task, planned.target, ctx);
|
|
1235
|
+
return draftFromTask(commandName, task, planned.target, ctx, draftPlanFactory, draftRuntime);
|
|
470
1236
|
};
|
|
471
1237
|
|
|
472
1238
|
if (parsed.mode === "task") {
|
|
473
1239
|
return handleTaskFlow(parsed.value);
|
|
474
1240
|
}
|
|
475
1241
|
if (parsed.mode === "path") {
|
|
476
|
-
return handleExistingInspection(parsed.value || ".", true);
|
|
1242
|
+
return handleExistingInspection(parsed.value || ".", true, parsed.runtimeArgs.length > 0);
|
|
477
1243
|
}
|
|
478
1244
|
if (!parsed.value) {
|
|
479
1245
|
const inspection = inspectExistingTarget(".", ctx.cwd);
|
|
@@ -491,49 +1257,107 @@ export default function (pi: ExtensionAPI) {
|
|
|
491
1257
|
return handleExistingInspection(parsed.value);
|
|
492
1258
|
}
|
|
493
1259
|
|
|
494
|
-
pi.on("tool_call", async (event:
|
|
495
|
-
|
|
496
|
-
const persisted = readPersistedLoopState(ctx);
|
|
1260
|
+
pi.on("tool_call", async (event: ToolEvent, ctx: EventContext) => {
|
|
1261
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
497
1262
|
if (!persisted) return;
|
|
498
1263
|
|
|
1264
|
+
if (persisted.envMalformed && (event.toolName === "bash" || event.toolName === "write" || event.toolName === "edit")) {
|
|
1265
|
+
return { block: true, reason: "ralph: invalid loop contract" };
|
|
1266
|
+
}
|
|
1267
|
+
|
|
499
1268
|
if (event.toolName === "bash") {
|
|
500
1269
|
const cmd = (event.input as { command?: string }).command ?? "";
|
|
501
1270
|
const blockedPattern = findBlockedCommandPattern(cmd, persisted.guardrails?.blockCommands ?? []);
|
|
502
|
-
if (blockedPattern)
|
|
1271
|
+
if (blockedPattern) {
|
|
1272
|
+
appendLoopProofEntry("ralph-blocked-command", {
|
|
1273
|
+
loopToken: persisted.loopToken,
|
|
1274
|
+
iteration: persisted.iteration,
|
|
1275
|
+
command: cmd,
|
|
1276
|
+
blockedPattern,
|
|
1277
|
+
});
|
|
1278
|
+
return { block: true, reason: `ralph: blocked (${blockedPattern})` };
|
|
1279
|
+
}
|
|
503
1280
|
}
|
|
504
1281
|
|
|
505
1282
|
if (event.toolName === "write" || event.toolName === "edit") {
|
|
506
1283
|
const filePath = (event.input as { path?: string }).path ?? "";
|
|
507
|
-
|
|
508
|
-
|
|
1284
|
+
if (matchesProtectedPath(filePath, persisted.guardrails?.protectedFiles ?? [], persisted.cwd)) {
|
|
1285
|
+
appendLoopProofEntry("ralph-blocked-write", {
|
|
1286
|
+
loopToken: persisted.loopToken,
|
|
1287
|
+
iteration: persisted.iteration,
|
|
1288
|
+
toolName: event.toolName,
|
|
1289
|
+
path: filePath,
|
|
1290
|
+
reason: `ralph: ${filePath} is protected`,
|
|
1291
|
+
});
|
|
1292
|
+
return { block: true, reason: `ralph: ${filePath} is protected` };
|
|
509
1293
|
}
|
|
510
1294
|
}
|
|
1295
|
+
|
|
1296
|
+
recordPendingToolPath(ctx, event);
|
|
511
1297
|
});
|
|
512
1298
|
|
|
513
|
-
pi.on("
|
|
514
|
-
|
|
515
|
-
|
|
1299
|
+
pi.on("tool_execution_start", async (event: ToolEvent, ctx: EventContext) => {
|
|
1300
|
+
recordPendingToolPath(ctx, event);
|
|
1301
|
+
});
|
|
1302
|
+
|
|
1303
|
+
pi.on("tool_execution_end", async (event: ToolEvent, ctx: EventContext) => {
|
|
1304
|
+
recordSuccessfulTaskDirWrite(ctx, event);
|
|
1305
|
+
});
|
|
1306
|
+
|
|
1307
|
+
pi.on("agent_end", async (event: AgentEndEvent, ctx: EventContext) => {
|
|
1308
|
+
resolvePendingIteration(ctx, event);
|
|
1309
|
+
});
|
|
1310
|
+
|
|
1311
|
+
pi.on("before_agent_start", async (event: BeforeAgentStartEvent, ctx: EventContext) => {
|
|
1312
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1313
|
+
if (!persisted) return;
|
|
516
1314
|
const summaries = persisted?.iterationSummaries ?? [];
|
|
517
1315
|
if (summaries.length === 0) return;
|
|
518
1316
|
|
|
519
|
-
const history = summaries
|
|
1317
|
+
const history = summaries
|
|
1318
|
+
.map((summary) => {
|
|
1319
|
+
const status = summarizeIterationProgress(summary);
|
|
1320
|
+
return `- Iteration ${summary.iteration}: ${summary.duration}s — ${status}; no-progress streak: ${summary.noProgressStreak ?? persisted?.noProgressStreak ?? 0}`;
|
|
1321
|
+
})
|
|
1322
|
+
.join("\n");
|
|
1323
|
+
const lastSummary = summaries[summaries.length - 1];
|
|
1324
|
+
const lastFeedback = summarizeLastIterationFeedback(lastSummary, persisted?.noProgressStreak ?? 0);
|
|
1325
|
+
const taskDirLabel = persisted?.taskDir ? displayPath(persisted.cwd ?? persisted.taskDir, persisted.taskDir) : "the Ralph task directory";
|
|
1326
|
+
appendLoopProofEntry("ralph-steering-injected", {
|
|
1327
|
+
loopToken: persisted?.loopToken,
|
|
1328
|
+
iteration: persisted?.iteration,
|
|
1329
|
+
maxIterations: persisted?.maxIterations,
|
|
1330
|
+
taskDir: taskDirLabel,
|
|
1331
|
+
});
|
|
1332
|
+
appendLoopProofEntry("ralph-loop-context-injected", {
|
|
1333
|
+
loopToken: persisted?.loopToken,
|
|
1334
|
+
iteration: persisted?.iteration,
|
|
1335
|
+
maxIterations: persisted?.maxIterations,
|
|
1336
|
+
taskDir: taskDirLabel,
|
|
1337
|
+
summaryCount: summaries.length,
|
|
1338
|
+
});
|
|
1339
|
+
|
|
520
1340
|
return {
|
|
521
1341
|
systemPrompt:
|
|
522
1342
|
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.`,
|
|
1343
|
+
`\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
1344
|
};
|
|
525
1345
|
});
|
|
526
1346
|
|
|
527
|
-
pi.on("tool_result", async (event:
|
|
528
|
-
|
|
529
|
-
|
|
1347
|
+
pi.on("tool_result", async (event: ToolResultEvent, ctx: EventContext) => {
|
|
1348
|
+
const persisted = resolveActiveLoopState(ctx);
|
|
1349
|
+
if (!persisted) return;
|
|
1350
|
+
|
|
1351
|
+
if (event.toolName !== "bash") return;
|
|
1352
|
+
const output = event.content.map((c) => (c.type === "text" ? c.text ?? "" : "")).join("");
|
|
530
1353
|
if (!shouldWarnForBashFailure(output)) return;
|
|
531
1354
|
|
|
532
|
-
const
|
|
533
|
-
if (!
|
|
1355
|
+
const state = resolveActiveIterationState(ctx);
|
|
1356
|
+
if (!state) return;
|
|
534
1357
|
|
|
535
|
-
const
|
|
536
|
-
failCounts.
|
|
1358
|
+
const failKey = getLoopIterationKey(state.loopToken, state.iteration);
|
|
1359
|
+
const next = (failCounts.get(failKey) ?? 0) + 1;
|
|
1360
|
+
failCounts.set(failKey, next);
|
|
537
1361
|
if (next >= 3) {
|
|
538
1362
|
return {
|
|
539
1363
|
content: [
|
|
@@ -546,7 +1370,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
546
1370
|
|
|
547
1371
|
pi.registerCommand("ralph", {
|
|
548
1372
|
description: "Start Ralph from a task folder or RALPH.md",
|
|
549
|
-
handler: async (args: string, ctx:
|
|
1373
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
550
1374
|
if (loopState.active) {
|
|
551
1375
|
ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
|
|
552
1376
|
return;
|
|
@@ -554,33 +1378,106 @@ export default function (pi: ExtensionAPI) {
|
|
|
554
1378
|
|
|
555
1379
|
const ralphPath = await handleDraftCommand("ralph", args ?? "", ctx);
|
|
556
1380
|
if (!ralphPath) return;
|
|
557
|
-
await startRalphLoop(ralphPath, ctx);
|
|
1381
|
+
await startRalphLoop(ralphPath, ctx, services.runRalphLoopFn, runtimeArgsForStart);
|
|
558
1382
|
},
|
|
559
1383
|
});
|
|
560
1384
|
|
|
561
1385
|
pi.registerCommand("ralph-draft", {
|
|
562
1386
|
description: "Draft a Ralph task without starting it",
|
|
563
|
-
handler: async (args: string, ctx:
|
|
1387
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
564
1388
|
await handleDraftCommand("ralph-draft", args ?? "", ctx);
|
|
565
1389
|
},
|
|
566
1390
|
});
|
|
567
1391
|
|
|
568
1392
|
pi.registerCommand("ralph-stop", {
|
|
569
1393
|
description: "Stop the ralph loop after the current iteration",
|
|
570
|
-
handler: async (
|
|
571
|
-
const
|
|
572
|
-
if (
|
|
573
|
-
|
|
574
|
-
|
|
1394
|
+
handler: async (args: string, ctx: CommandContext) => {
|
|
1395
|
+
const parsed = parseCommandArgs(args ?? "");
|
|
1396
|
+
if (parsed.error) {
|
|
1397
|
+
ctx.ui.notify(parsed.error, "error");
|
|
1398
|
+
return;
|
|
1399
|
+
}
|
|
1400
|
+
if (parsed.mode === "task") {
|
|
1401
|
+
ctx.ui.notify("/ralph-stop expects a task folder or RALPH.md path, not task text.", "error");
|
|
1402
|
+
return;
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
const now = new Date().toISOString();
|
|
1406
|
+
const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
|
|
1407
|
+
const { target: sessionTarget, persistedSessionState } = resolveSessionStopTarget(ctx, now);
|
|
1408
|
+
|
|
1409
|
+
if (sessionTarget && !parsed.value) {
|
|
1410
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
if (parsed.value) {
|
|
1415
|
+
const inspection = inspectExistingTarget(parsed.value, ctx.cwd, true);
|
|
1416
|
+
if (inspection.kind !== "run") {
|
|
1417
|
+
if (inspection.kind === "invalid-markdown") {
|
|
1418
|
+
ctx.ui.notify(`Only task folders or RALPH.md can be stopped directly. ${displayPath(ctx.cwd, inspection.path)} is not stoppable.`, "error");
|
|
1419
|
+
return;
|
|
1420
|
+
}
|
|
1421
|
+
if (inspection.kind === "invalid-target") {
|
|
1422
|
+
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");
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
if (inspection.kind === "dir-without-ralph" || inspection.kind === "missing-path") {
|
|
1426
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.dirPath)}.`, "warning");
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
ctx.ui.notify("/ralph-stop expects a task folder or RALPH.md path.", "error");
|
|
1430
|
+
return;
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
const taskDir = dirname(inspection.ralphPath);
|
|
1434
|
+
if (sessionTarget && sessionTarget.taskDir === taskDir) {
|
|
1435
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir || entry.ralphPath === inspection.ralphPath);
|
|
1440
|
+
if (registryTarget) {
|
|
1441
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(registryTarget), now);
|
|
575
1442
|
return;
|
|
576
1443
|
}
|
|
577
|
-
|
|
578
|
-
|
|
1444
|
+
|
|
1445
|
+
const statusFile = readStatusFile(taskDir);
|
|
1446
|
+
if (
|
|
1447
|
+
statusFile &&
|
|
1448
|
+
(statusFile.status === "running" || statusFile.status === "initializing") &&
|
|
1449
|
+
typeof statusFile.cwd === "string" &&
|
|
1450
|
+
statusFile.cwd.length > 0
|
|
1451
|
+
) {
|
|
1452
|
+
const statusRegistryTarget = listActiveLoopRegistryEntries(statusFile.cwd).find(
|
|
1453
|
+
(entry) => entry.taskDir === taskDir && entry.loopToken === statusFile.loopToken,
|
|
1454
|
+
);
|
|
1455
|
+
if (statusRegistryTarget) {
|
|
1456
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(statusRegistryTarget), now);
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.ralphPath)}.`, "warning");
|
|
579
1462
|
return;
|
|
580
1463
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
1464
|
+
|
|
1465
|
+
if (sessionTarget) {
|
|
1466
|
+
applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const activeEntries = activeRegistryEntries();
|
|
1471
|
+
if (activeEntries.length === 0) {
|
|
1472
|
+
ctx.ui.notify("No active ralph loops found.", "warning");
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (activeEntries.length > 1) {
|
|
1476
|
+
ctx.ui.notify("Multiple active ralph loops found. Use /ralph-stop --path <task folder or RALPH.md> for an explicit target path.", "error");
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
applyStopTarget(pi, ctx, materializeRegistryStopTarget(activeEntries[0]), now);
|
|
584
1481
|
},
|
|
585
1482
|
});
|
|
586
1483
|
}
|