@lnilluv/pi-ralph-loop 0.2.1 → 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.
Files changed (48) hide show
  1. package/.github/workflows/ci.yml +5 -2
  2. package/.github/workflows/release.yml +15 -43
  3. package/README.md +51 -113
  4. package/package.json +13 -5
  5. package/scripts/version-helper.ts +210 -0
  6. package/src/index.ts +1360 -275
  7. package/src/ralph-draft-context.ts +618 -0
  8. package/src/ralph-draft-llm.ts +297 -0
  9. package/src/ralph-draft.ts +33 -0
  10. package/src/ralph.ts +1457 -0
  11. package/src/runner-rpc.ts +434 -0
  12. package/src/runner-state.ts +822 -0
  13. package/src/runner.ts +957 -0
  14. package/src/secret-paths.ts +66 -0
  15. package/src/shims.d.ts +0 -3
  16. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  17. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  18. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  19. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  20. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  21. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  22. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  23. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  24. package/tests/fixtures/parity/research/RALPH.md +45 -0
  25. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  26. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  27. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  28. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  29. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  30. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  31. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  32. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  33. package/tests/index.test.ts +3529 -0
  34. package/tests/parity/README.md +9 -0
  35. package/tests/parity/harness.py +526 -0
  36. package/tests/parity-harness.test.ts +42 -0
  37. package/tests/parity-research-fixture.test.ts +34 -0
  38. package/tests/ralph-draft-context.test.ts +672 -0
  39. package/tests/ralph-draft-llm.test.ts +434 -0
  40. package/tests/ralph-draft.test.ts +168 -0
  41. package/tests/ralph.test.ts +1840 -0
  42. package/tests/runner-event-contract.test.ts +235 -0
  43. package/tests/runner-rpc.test.ts +358 -0
  44. package/tests/runner-state.test.ts +553 -0
  45. package/tests/runner.test.ts +1347 -0
  46. package/tests/secret-paths.test.ts +55 -0
  47. package/tests/version-helper.test.ts +75 -0
  48. package/tsconfig.json +3 -2
package/src/index.ts CHANGED
@@ -1,129 +1,184 @@
1
- import { parse as parseYaml } from "yaml";
2
- import { minimatch } from "minimatch";
3
- import { readFileSync, existsSync } from "node:fs";
4
- import { resolve, join, dirname, basename } from "node:path";
5
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
-
7
- type CommandDef = { name: string; run: string; timeout: number };
8
- type Frontmatter = {
9
- commands: CommandDef[];
10
- maxIterations: number;
11
- timeout: number;
12
- completionPromise?: string;
13
- guardrails: { blockCommands: string[]; protectedFiles: string[] };
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
+ import {
6
+ buildMissionBrief,
7
+ inspectExistingTarget,
8
+ parseCommandArgs,
9
+ parseRalphMarkdown,
10
+ planTaskDraftTarget,
11
+ renderIterationPrompt,
12
+ renderRalphBody,
13
+ resolveCommandRun,
14
+ replaceArgsPlaceholders,
15
+ runtimeArgEntriesToMap,
16
+ shouldStopForCompletionPromise,
17
+ shouldWarnForBashFailure,
18
+ shouldValidateExistingDraft,
19
+ validateDraftContent,
20
+ validateFrontmatter as validateFrontmatterMessage,
21
+ validateRuntimeArgs,
22
+ createSiblingTarget,
23
+ findBlockedCommandPattern,
24
+ } from "./ralph.ts";
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;
14
52
  };
15
- type ParsedRalph = { frontmatter: Frontmatter; body: string };
16
- type CommandOutput = { name: string; output: string };
53
+
17
54
  type LoopState = {
18
55
  active: boolean;
19
56
  ralphPath: string;
57
+ taskDir: string;
58
+ cwd: string;
20
59
  iteration: number;
21
60
  maxIterations: number;
22
61
  timeout: number;
23
62
  completionPromise?: string;
24
63
  stopRequested: boolean;
25
- iterationSummaries: Array<{ iteration: number; duration: number }>;
64
+ noProgressStreak: number;
65
+ iterationSummaries: IterationSummary[];
26
66
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
27
- loopSessionFile?: string;
67
+ observedTaskDirWrites: Set<string>;
68
+ loopToken?: string;
28
69
  };
29
70
  type PersistedLoopState = {
30
71
  active: boolean;
31
- sessionFile?: string;
72
+ loopToken?: string;
73
+ cwd?: string;
74
+ taskDir?: string;
32
75
  iteration?: number;
33
76
  maxIterations?: number;
34
- iterationSummaries?: Array<{ iteration: number; duration: number }>;
77
+ noProgressStreak?: number;
78
+ iterationSummaries?: IterationSummary[];
35
79
  guardrails?: { blockCommands: string[]; protectedFiles: string[] };
36
80
  stopRequested?: boolean;
37
81
  };
38
82
 
39
- function defaultFrontmatter(): Frontmatter {
40
- return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
41
- }
83
+ type ActiveLoopState = PersistedLoopState & { active: true; loopToken: string; envMalformed?: boolean };
84
+ type ActiveIterationState = ActiveLoopState & { iteration: number };
42
85
 
43
- function parseRalphMd(filePath: string): ParsedRalph {
44
- let raw = readFileSync(filePath, "utf8");
45
- raw = raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
46
- const match = raw.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
47
- if (!match) return { frontmatter: defaultFrontmatter(), body: raw };
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";
48
93
 
49
- const yaml = (parseYaml(match[1]) ?? {}) as Record<string, any>;
50
- const commands: CommandDef[] = Array.isArray(yaml.commands)
51
- ? yaml.commands.map((c: Record<string, any>) => ({ name: String(c.name ?? ""), run: String(c.run ?? ""), timeout: Number(c.timeout ?? 60) }))
52
- : [];
53
- const guardrails = (yaml.guardrails ?? {}) as Record<string, any>;
94
+ type CommandContext = ExtensionCommandContext;
95
+ type CommandSessionEntry = SessionEntry;
54
96
 
55
- return {
56
- frontmatter: {
57
- commands,
58
- maxIterations: Number(yaml.max_iterations ?? 50),
59
- timeout: Number(yaml.timeout ?? 300),
60
- completionPromise:
61
- typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
62
- guardrails: {
63
- blockCommands: Array.isArray(guardrails.block_commands) ? guardrails.block_commands.map((p: unknown) => String(p)) : [],
64
- protectedFiles: Array.isArray(guardrails.protected_files) ? guardrails.protected_files.map((p: unknown) => String(p)) : [],
65
- },
66
- },
67
- body: match[2] ?? "",
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;
68
128
  };
69
- }
129
+ isError?: boolean;
130
+ success?: boolean;
131
+ };
70
132
 
71
- function validateFrontmatter(fm: Frontmatter, ctx: any): boolean {
72
- if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
73
- ctx.ui.notify("Invalid max_iterations: must be a positive finite integer", "error");
74
- return false;
75
- }
76
- if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
77
- ctx.ui.notify("Invalid timeout: must be a positive finite number", "error");
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 {
145
+ const error = validateFrontmatterMessage(fm);
146
+ if (error) {
147
+ ctx.ui.notify(error, "error");
78
148
  return false;
79
149
  }
80
- for (const pattern of fm.guardrails.blockCommands) {
81
- try { new RegExp(pattern); } catch {
82
- ctx.ui.notify(`Invalid block_commands regex: ${pattern}`, "error");
83
- return false;
84
- }
85
- }
86
- for (const cmd of fm.commands) {
87
- if (!cmd.name.trim()) {
88
- ctx.ui.notify("Invalid command: name is required", "error");
89
- return false;
90
- }
91
- if (!cmd.run.trim()) {
92
- ctx.ui.notify(`Invalid command ${cmd.name}: run is required`, "error");
93
- return false;
94
- }
95
- if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
96
- ctx.ui.notify(`Invalid command ${cmd.name}: timeout must be positive`, "error");
97
- return false;
98
- }
99
- }
100
150
  return true;
101
151
  }
102
152
 
103
- function resolveRalphPath(args: string, cwd: string): string {
104
- const target = args.trim() || ".";
105
- const abs = resolve(cwd, target);
106
- if (existsSync(abs) && abs.endsWith(".md")) return abs;
107
- if (existsSync(join(abs, "RALPH.md"))) return join(abs, "RALPH.md");
108
- throw new Error(`No RALPH.md found at ${abs}`);
109
- }
110
-
111
- function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
112
- const map = new Map(outputs.map((o) => [o.name, o.output]));
113
- return body
114
- .replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
115
- .replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
116
- .replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
117
- }
118
-
119
- async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<CommandOutput[]> {
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();
120
162
  const results: CommandOutput[] = [];
121
163
  for (const cmd of commands) {
164
+ const semanticRun = replaceArgsPlaceholders(cmd.run, runtimeArgs);
165
+ const blockedPattern = findBlockedCommandPattern(semanticRun, blockPatterns);
166
+ const resolvedRun = resolveCommandRun(cmd.run, runtimeArgs);
167
+ if (blockedPattern) {
168
+ pi.appendEntry?.("ralph-blocked-command", { name: cmd.name, command: semanticRun, blockedPattern, cwd: repoCwd, taskDir });
169
+ results.push({ name: cmd.name, output: `[blocked by guardrail: ${blockedPattern}]` });
170
+ continue;
171
+ }
172
+
173
+ const commandCwd = semanticRun.trim().startsWith("./") ? taskDir ?? repoCwd : repoCwd;
174
+
122
175
  try {
123
- const result = await pi.exec("bash", ["-c", cmd.run], { timeout: cmd.timeout * 1000 });
124
- results.push(result.killed
125
- ? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
126
- : { name: cmd.name, output: (result.stdout + result.stderr).trim() });
176
+ const result = await pi.exec("bash", ["-c", resolvedRun], { timeout: cmd.timeout * 1000, cwd: commandCwd });
177
+ results.push(
178
+ result.killed
179
+ ? { name: cmd.name, output: `[timed out after ${cmd.timeout}s]` }
180
+ : { name: cmd.name, output: (result.stdout + result.stderr).trim() },
181
+ );
127
182
  } catch (err) {
128
183
  const message = err instanceof Error ? err.message : String(err);
129
184
  results.push({ name: cmd.name, output: `[error: ${message}]` });
@@ -132,11 +187,99 @@ async function runCommands(commands: CommandDef[], pi: ExtensionAPI): Promise<Co
132
187
  return results;
133
188
  }
134
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
+
135
263
  function defaultLoopState(): LoopState {
136
- return { active: false, ralphPath: "", iteration: 0, maxIterations: 50, timeout: 300, completionPromise: undefined, stopRequested: false, iterationSummaries: [], guardrails: { blockCommands: [], protectedFiles: [] }, loopSessionFile: undefined };
264
+ return {
265
+ active: false,
266
+ ralphPath: "",
267
+ taskDir: "",
268
+ iteration: 0,
269
+ maxIterations: 50,
270
+ timeout: 300,
271
+ completionPromise: undefined,
272
+ stopRequested: false,
273
+ noProgressStreak: 0,
274
+ iterationSummaries: [],
275
+ guardrails: { blockCommands: [], protectedFiles: [] },
276
+ observedTaskDirWrites: new Set(),
277
+ loopToken: undefined,
278
+ cwd: "",
279
+ };
137
280
  }
138
281
 
139
- function readPersistedLoopState(ctx: any): PersistedLoopState | undefined {
282
+ function readPersistedLoopState(ctx: Pick<CommandContext, "sessionManager">): PersistedLoopState | undefined {
140
283
  const entries = ctx.sessionManager.getEntries();
141
284
  for (let i = entries.length - 1; i >= 0; i--) {
142
285
  const entry = entries[i];
@@ -151,64 +294,1070 @@ function persistLoopState(pi: ExtensionAPI, data: PersistedLoopState) {
151
294
  pi.appendEntry("ralph-loop-state", data);
152
295
  }
153
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
+
710
+ function writeDraftFile(ralphPath: string, content: string) {
711
+ mkdirSync(dirname(ralphPath), { recursive: true });
712
+ writeFileSync(ralphPath, content, "utf8");
713
+ }
714
+
715
+ function displayPath(cwd: string, filePath: string): string {
716
+ const rel = relative(cwd, filePath);
717
+ return rel && !rel.startsWith("..") ? `./${rel}` : filePath;
718
+ }
719
+
720
+ async function promptForTask(ctx: Pick<CommandContext, "hasUI" | "ui">, title: string, placeholder: string): Promise<string | undefined> {
721
+ if (!ctx.hasUI) return undefined;
722
+ const value = await ctx.ui.input(title, placeholder);
723
+ const trimmed = value?.trim();
724
+ return trimmed ? trimmed : undefined;
725
+ }
726
+
727
+ async function reviewDraft(plan: DraftPlan, mode: "run" | "draft", ctx: Pick<CommandContext, "ui">): Promise<{ action: "start" | "save" | "cancel"; content: string }> {
728
+ let content = plan.content;
729
+
730
+ while (true) {
731
+ const nextPlan = { ...plan, content };
732
+ const contentError = validateDraftContent(content);
733
+ const options = contentError
734
+ ? ["Open RALPH.md", "Cancel"]
735
+ : mode === "run"
736
+ ? ["Start", "Open RALPH.md", "Cancel"]
737
+ : ["Save draft", "Open RALPH.md", "Cancel"];
738
+ const choice = await ctx.ui.select(buildMissionBrief(nextPlan), options);
739
+
740
+ if (!choice || choice === "Cancel") {
741
+ return { action: "cancel", content };
742
+ }
743
+ if (choice === "Open RALPH.md") {
744
+ const edited = await ctx.ui.editor("Edit RALPH.md", content);
745
+ if (typeof edited === "string") content = edited;
746
+ continue;
747
+ }
748
+ if (contentError) {
749
+ ctx.ui.notify(`Invalid RALPH.md: ${contentError}`, "error");
750
+ continue;
751
+ }
752
+ if (choice === "Save draft") {
753
+ return { action: "save", content };
754
+ }
755
+ return { action: "start", content };
756
+ }
757
+ }
758
+
759
+ async function editExistingDraft(ralphPath: string, ctx: Pick<CommandContext, "cwd" | "hasUI" | "ui">, saveMessage = "Saved RALPH.md") {
760
+ if (!ctx.hasUI) {
761
+ ctx.ui.notify(`Use ${displayPath(ctx.cwd, ralphPath)} in an interactive session to edit the draft.`, "warning");
762
+ return;
763
+ }
764
+
765
+ let content = readFileSync(ralphPath, "utf8");
766
+ const strictValidation = shouldValidateExistingDraft(content);
767
+ while (true) {
768
+ const edited = await ctx.ui.editor("Edit RALPH.md", content);
769
+ if (typeof edited !== "string") return;
770
+
771
+ if (strictValidation) {
772
+ const error = validateDraftContent(edited);
773
+ if (error) {
774
+ ctx.ui.notify(`Invalid RALPH.md: ${error}`, "error");
775
+ content = edited;
776
+ continue;
777
+ }
778
+ }
779
+
780
+ if (edited !== content) {
781
+ writeDraftFile(ralphPath, edited);
782
+ ctx.ui.notify(saveMessage, "info");
783
+ }
784
+ return;
785
+ }
786
+ }
787
+
788
+ async function chooseRecoveryMode(
789
+ input: string,
790
+ dirPath: string,
791
+ ctx: Pick<CommandContext, "cwd" | "ui">,
792
+ allowTaskFallback = true,
793
+ ): Promise<"draft-path" | "task" | "cancel"> {
794
+ const options = allowTaskFallback ? ["Draft in that folder", "Treat as task text", "Cancel"] : ["Draft in that folder", "Cancel"];
795
+ const choice = await ctx.ui.select(`No RALPH.md in ${displayPath(ctx.cwd, dirPath)}.`, options);
796
+ if (choice === "Draft in that folder") return "draft-path";
797
+ if (choice === "Treat as task text") return "task";
798
+ return "cancel";
799
+ }
800
+
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 }> {
802
+ const hasExistingDraft = existsSync(target.ralphPath);
803
+ const title = hasExistingDraft
804
+ ? `Found an existing RALPH at ${displayPath(ctx.cwd, target.ralphPath)} for “${task}”.`
805
+ : `Found an occupied draft directory at ${displayPath(ctx.cwd, target.dirPath)} for “${task}”.`;
806
+ const options =
807
+ commandName === "ralph"
808
+ ? hasExistingDraft
809
+ ? ["Run existing", "Open existing RALPH.md", "Create sibling", "Cancel"]
810
+ : ["Create sibling", "Cancel"]
811
+ : hasExistingDraft
812
+ ? ["Open existing RALPH.md", "Create sibling", "Cancel"]
813
+ : ["Create sibling", "Cancel"];
814
+ const choice = await ctx.ui.select(title, options);
815
+
816
+ if (!choice || choice === "Cancel") return { action: "cancel" };
817
+ if (choice === "Run existing") return { action: "run-existing" };
818
+ if (choice === "Open existing RALPH.md") return { action: "open-existing" };
819
+ return { action: "draft-target", target: createSiblingTarget(ctx.cwd, target.slug) };
820
+ }
821
+
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);
839
+ const review = await reviewDraft(plan, commandName === "ralph" ? "run" : "draft", ctx);
840
+ if (review.action === "cancel") return undefined;
841
+
842
+ writeDraftFile(target.ralphPath, review.content);
843
+ if (review.action === "save") {
844
+ ctx.ui.notify(`Draft saved to ${displayPath(ctx.cwd, target.ralphPath)}`, "info");
845
+ return undefined;
846
+ }
847
+ return target.ralphPath;
848
+ }
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
+
154
955
  let loopState: LoopState = defaultLoopState();
956
+ const RALPH_EXTENSION_REGISTERED = Symbol.for("pi-ralph-loop.registered");
155
957
 
156
- 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;
157
962
  const failCounts = new Map<string, number>();
158
- const isLoopSession = (ctx: any): boolean => {
159
- const state = readPersistedLoopState(ctx);
160
- const sessionFile = ctx.sessionManager.getSessionFile();
161
- return state?.active === true && state.sessionFile === sessionFile;
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);
162
1031
  };
163
1032
 
164
- pi.on("tool_call", async (event: any, ctx: any) => {
165
- if (!isLoopSession(ctx)) return;
166
- const persisted = readPersistedLoopState(ctx);
1033
+ async function startRalphLoop(ralphPath: string, ctx: CommandContext, runLoopFn: typeof runRalphLoop = runRalphLoop, runtimeArgs: RuntimeArgs = {}) {
1034
+ let name: string;
1035
+ try {
1036
+ const raw = readFileSync(ralphPath, "utf8");
1037
+ const draftError = validateDraftContent(raw);
1038
+ if (draftError) {
1039
+ ctx.ui.notify(`Invalid RALPH.md: ${draftError}`, "error");
1040
+ return;
1041
+ }
1042
+ const parsed = parseRalphMarkdown(raw);
1043
+ const { frontmatter } = parsed;
1044
+ if (!validateFrontmatter(frontmatter, ctx)) return;
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);
1052
+ loopState = {
1053
+ active: true,
1054
+ ralphPath,
1055
+ taskDir,
1056
+ cwd: ctx.cwd,
1057
+ iteration: 0,
1058
+ maxIterations: frontmatter.maxIterations,
1059
+ timeout: frontmatter.timeout,
1060
+ completionPromise: frontmatter.completionPromise,
1061
+ stopRequested: false,
1062
+ noProgressStreak: 0,
1063
+ iterationSummaries: [],
1064
+ guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
1065
+ observedTaskDirWrites: new Set(),
1066
+ loopToken: randomUUID(),
1067
+ };
1068
+ } catch (err) {
1069
+ ctx.ui.notify(String(err), "error");
1070
+ return;
1071
+ }
1072
+ ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
1073
+
1074
+ try {
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
+ });
1114
+
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");
1120
+ break;
1121
+ case "max-iterations":
1122
+ ctx.ui.notify(`Ralph loop reached max iterations: ${result.iterations.length} iterations, ${total}s total`, "info");
1123
+ break;
1124
+ case "no-progress-exhaustion":
1125
+ ctx.ui.notify(`Ralph loop exhausted without verified progress: ${result.iterations.length} iterations, ${total}s total`, "warning");
1126
+ break;
1127
+ case "stopped":
1128
+ ctx.ui.notify(`Ralph loop stopped: ${result.iterations.length} iterations, ${total}s total`, "info");
1129
+ break;
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");
1138
+ break;
1139
+ }
1140
+ } catch (err) {
1141
+ const message = err instanceof Error ? err.message : String(err);
1142
+ ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
1143
+ } finally {
1144
+ failCounts.clear();
1145
+ pendingIterations.clear();
1146
+ loopState.active = false;
1147
+ loopState.stopRequested = false;
1148
+ loopState.loopToken = undefined;
1149
+ ctx.ui.setStatus("ralph", undefined);
1150
+ persistLoopState(pi, toPersistedLoopState(loopState, { active: false, stopRequested: false }));
1151
+ }
1152
+ }
1153
+
1154
+ let runtimeArgsForStart: RuntimeArgs = {};
1155
+
1156
+ async function handleDraftCommand(commandName: "ralph" | "ralph-draft", args: string, ctx: CommandContext): Promise<string | undefined> {
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);
1174
+
1175
+ const resolveTaskForFolder = async (target: DraftTarget): Promise<string | undefined> => {
1176
+ const task = await promptForTask(ctx, "What should Ralph work on in this folder?", "reverse engineer this app");
1177
+ if (!task) return undefined;
1178
+ return draftFromTask(commandName, task, target, ctx, draftPlanFactory, draftRuntime);
1179
+ };
1180
+
1181
+ const handleExistingInspection = async (input: string, explicitPath = false, runtimeArgsProvided = false): Promise<string | undefined> => {
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
+ }
1187
+ switch (inspection.kind) {
1188
+ case "run":
1189
+ if (commandName === "ralph") return inspection.ralphPath;
1190
+ await editExistingDraft(inspection.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, inspection.ralphPath)}`);
1191
+ return undefined;
1192
+ case "invalid-markdown":
1193
+ ctx.ui.notify(`Only task folders or RALPH.md can be run directly. ${displayPath(ctx.cwd, inspection.path)} is not runnable.`, "error");
1194
+ return undefined;
1195
+ case "invalid-target":
1196
+ ctx.ui.notify(`Only task folders or RALPH.md can be run directly. ${displayPath(ctx.cwd, inspection.path)} is a file, not a task folder.`, "error");
1197
+ return undefined;
1198
+ case "dir-without-ralph":
1199
+ case "missing-path": {
1200
+ if (!ctx.hasUI) {
1201
+ ctx.ui.notify("Draft review requires an interactive session. Pass a task folder or RALPH.md path instead.", "warning");
1202
+ return undefined;
1203
+ }
1204
+ const recovery = await chooseRecoveryMode(input, inspection.dirPath, ctx, !explicitPath);
1205
+ if (recovery === "cancel") return undefined;
1206
+ if (recovery === "task") {
1207
+ return handleTaskFlow(input);
1208
+ }
1209
+ return resolveTaskForFolder({ slug: basename(inspection.dirPath), dirPath: inspection.dirPath, ralphPath: inspection.ralphPath });
1210
+ }
1211
+ case "not-path":
1212
+ return handleTaskFlow(input);
1213
+ }
1214
+ };
1215
+
1216
+ const handleTaskFlow = async (taskInput: string): Promise<string | undefined> => {
1217
+ const task = taskInput.trim();
1218
+ if (!task) return undefined;
1219
+ if (!ctx.hasUI) {
1220
+ ctx.ui.notify("Draft review requires an interactive session. Use /ralph with a task folder or RALPH.md path instead.", "warning");
1221
+ return undefined;
1222
+ }
1223
+
1224
+ let planned = planTaskDraftTarget(ctx.cwd, task);
1225
+ if (planned.kind === "conflict") {
1226
+ const decision = await chooseConflictTarget(commandName, task, planned.target, ctx);
1227
+ if (decision.action === "cancel") return undefined;
1228
+ if (decision.action === "run-existing") return planned.target.ralphPath;
1229
+ if (decision.action === "open-existing") {
1230
+ await editExistingDraft(planned.target.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, planned.target.ralphPath)}`);
1231
+ return undefined;
1232
+ }
1233
+ planned = { kind: "draft", target: decision.target! };
1234
+ }
1235
+ return draftFromTask(commandName, task, planned.target, ctx, draftPlanFactory, draftRuntime);
1236
+ };
1237
+
1238
+ if (parsed.mode === "task") {
1239
+ return handleTaskFlow(parsed.value);
1240
+ }
1241
+ if (parsed.mode === "path") {
1242
+ return handleExistingInspection(parsed.value || ".", true, parsed.runtimeArgs.length > 0);
1243
+ }
1244
+ if (!parsed.value) {
1245
+ const inspection = inspectExistingTarget(".", ctx.cwd);
1246
+ if (inspection.kind === "run") {
1247
+ if (commandName === "ralph") return inspection.ralphPath;
1248
+ await editExistingDraft(inspection.ralphPath, ctx, `Saved ${displayPath(ctx.cwd, inspection.ralphPath)}`);
1249
+ return undefined;
1250
+ }
1251
+ if (!ctx.hasUI) {
1252
+ ctx.ui.notify("Draft review requires an interactive session. Pass a task folder or RALPH.md path instead.", "warning");
1253
+ return undefined;
1254
+ }
1255
+ return resolveTaskForFolder({ slug: basename(ctx.cwd), dirPath: ctx.cwd, ralphPath: join(ctx.cwd, "RALPH.md") });
1256
+ }
1257
+ return handleExistingInspection(parsed.value);
1258
+ }
1259
+
1260
+ pi.on("tool_call", async (event: ToolEvent, ctx: EventContext) => {
1261
+ const persisted = resolveActiveLoopState(ctx);
167
1262
  if (!persisted) return;
168
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
+
169
1268
  if (event.toolName === "bash") {
170
1269
  const cmd = (event.input as { command?: string }).command ?? "";
171
- for (const pattern of persisted.guardrails?.blockCommands ?? []) {
172
- try {
173
- if (new RegExp(pattern).test(cmd)) return { block: true, reason: `ralph: blocked (${pattern})` };
174
- } catch {
175
- // ignore malformed persisted regex
176
- }
1270
+ const blockedPattern = findBlockedCommandPattern(cmd, persisted.guardrails?.blockCommands ?? []);
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})` };
177
1279
  }
178
1280
  }
179
1281
 
180
1282
  if (event.toolName === "write" || event.toolName === "edit") {
181
1283
  const filePath = (event.input as { path?: string }).path ?? "";
182
- for (const glob of persisted.guardrails?.protectedFiles ?? []) {
183
- if (minimatch(filePath, glob, { matchBase: true })) return { block: true, reason: `ralph: ${filePath} is protected` };
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` };
184
1293
  }
185
1294
  }
1295
+
1296
+ recordPendingToolPath(ctx, event);
1297
+ });
1298
+
1299
+ pi.on("tool_execution_start", async (event: ToolEvent, ctx: EventContext) => {
1300
+ recordPendingToolPath(ctx, event);
186
1301
  });
187
1302
 
188
- pi.on("before_agent_start", async (event: any, ctx: any) => {
189
- if (!isLoopSession(ctx)) return;
190
- const persisted = readPersistedLoopState(ctx);
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;
191
1314
  const summaries = persisted?.iterationSummaries ?? [];
192
1315
  if (summaries.length === 0) return;
193
1316
 
194
- const history = summaries.map((s) => `- Iteration ${s.iteration}: ${s.duration}s`).join("\n");
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
+
195
1340
  return {
196
1341
  systemPrompt:
197
1342
  event.systemPrompt +
198
- `\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.`,
199
1344
  };
200
1345
  });
201
1346
 
202
- pi.on("tool_result", async (event: any, ctx: any) => {
203
- if (!isLoopSession(ctx) || event.toolName !== "bash") return;
204
- const output = event.content.map((c: { type: string; text?: string }) => (c.type === "text" ? c.text ?? "" : "")).join("");
205
- if (!/FAIL|ERROR|error:|failed/i.test(output)) return;
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("");
1353
+ if (!shouldWarnForBashFailure(output)) return;
206
1354
 
207
- const sessionFile = ctx.sessionManager.getSessionFile();
208
- if (!sessionFile) return;
1355
+ const state = resolveActiveIterationState(ctx);
1356
+ if (!state) return;
209
1357
 
210
- const next = (failCounts.get(sessionFile) ?? 0) + 1;
211
- failCounts.set(sessionFile, next);
1358
+ const failKey = getLoopIterationKey(state.loopToken, state.iteration);
1359
+ const next = (failCounts.get(failKey) ?? 0) + 1;
1360
+ failCounts.set(failKey, next);
212
1361
  if (next >= 3) {
213
1362
  return {
214
1363
  content: [
@@ -220,179 +1369,115 @@ export default function (pi: ExtensionAPI) {
220
1369
  });
221
1370
 
222
1371
  pi.registerCommand("ralph", {
223
- description: "Start an autonomous ralph loop from a RALPH.md file",
224
- handler: async (args: string, ctx: any) => {
1372
+ description: "Start Ralph from a task folder or RALPH.md",
1373
+ handler: async (args: string, ctx: CommandContext) => {
225
1374
  if (loopState.active) {
226
1375
  ctx.ui.notify("A ralph loop is already running. Use /ralph-stop first.", "warning");
227
1376
  return;
228
1377
  }
229
1378
 
230
- let name: string;
231
- try {
232
- const ralphPath = resolveRalphPath(args ?? "", ctx.cwd);
233
- const { frontmatter } = parseRalphMd(ralphPath);
234
- if (!validateFrontmatter(frontmatter, ctx)) return;
235
- name = basename(dirname(ralphPath));
236
- loopState = {
237
- active: true,
238
- ralphPath,
239
- iteration: 0,
240
- maxIterations: frontmatter.maxIterations,
241
- timeout: frontmatter.timeout,
242
- completionPromise: frontmatter.completionPromise,
243
- stopRequested: false,
244
- iterationSummaries: [],
245
- guardrails: { blockCommands: frontmatter.guardrails.blockCommands, protectedFiles: frontmatter.guardrails.protectedFiles },
246
- loopSessionFile: undefined,
247
- };
248
- } catch (err) {
249
- ctx.ui.notify(String(err), "error");
250
- return;
251
- }
252
- ctx.ui.notify(`Ralph loop started: ${name} (max ${loopState.maxIterations} iterations)`, "info");
1379
+ const ralphPath = await handleDraftCommand("ralph", args ?? "", ctx);
1380
+ if (!ralphPath) return;
1381
+ await startRalphLoop(ralphPath, ctx, services.runRalphLoopFn, runtimeArgsForStart);
1382
+ },
1383
+ });
253
1384
 
254
- try {
255
- iterationLoop: for (let i = 1; i <= loopState.maxIterations; i++) {
256
- if (loopState.stopRequested) break;
257
- const persistedBefore = readPersistedLoopState(ctx);
258
- if (persistedBefore?.active && persistedBefore.stopRequested) {
259
- loopState.stopRequested = true;
260
- ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
261
- break;
262
- }
1385
+ pi.registerCommand("ralph-draft", {
1386
+ description: "Draft a Ralph task without starting it",
1387
+ handler: async (args: string, ctx: CommandContext) => {
1388
+ await handleDraftCommand("ralph-draft", args ?? "", ctx);
1389
+ },
1390
+ });
263
1391
 
264
- loopState.iteration = i;
265
- const iterStart = Date.now();
266
- const { frontmatter: fm, body: rawBody } = parseRalphMd(loopState.ralphPath);
267
- if (!validateFrontmatter(fm, ctx)) {
268
- ctx.ui.notify(`Invalid RALPH.md on iteration ${i}, stopping loop`, "error");
269
- break;
270
- }
1392
+ pi.registerCommand("ralph-stop", {
1393
+ description: "Stop the ralph loop after the current iteration",
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
+ }
271
1404
 
272
- loopState.maxIterations = fm.maxIterations;
273
- loopState.timeout = fm.timeout;
274
- loopState.completionPromise = fm.completionPromise;
275
- loopState.guardrails = { blockCommands: fm.guardrails.blockCommands, protectedFiles: fm.guardrails.protectedFiles };
276
-
277
- const outputs = await runCommands(fm.commands, pi);
278
- let body = resolvePlaceholders(rawBody, outputs, { iteration: i, name });
279
- body = body.replace(/<!--[\s\S]*?-->/g, "");
280
- const prompt = `[ralph: iteration ${i}/${loopState.maxIterations}]\n\n${body}`;
281
-
282
- const prevPersisted = readPersistedLoopState(ctx);
283
- if (prevPersisted?.active && prevPersisted.sessionFile === ctx.sessionManager.getSessionFile()) persistLoopState(pi, { ...prevPersisted, active: false });
284
- ctx.ui.setStatus("ralph", `🔁 ${name}: iteration ${i}/${loopState.maxIterations}`);
285
- const prevSessionFile = loopState.loopSessionFile;
286
- const { cancelled } = await ctx.newSession();
287
- if (cancelled) {
288
- ctx.ui.notify("Session switch cancelled, stopping loop", "warning");
289
- break;
290
- }
1405
+ const now = new Date().toISOString();
1406
+ const activeRegistryEntries = () => listActiveLoopRegistryEntries(ctx.cwd);
1407
+ const { target: sessionTarget, persistedSessionState } = resolveSessionStopTarget(ctx, now);
291
1408
 
292
- loopState.loopSessionFile = ctx.sessionManager.getSessionFile();
293
- if (prevSessionFile && prevSessionFile !== loopState.loopSessionFile) failCounts.delete(prevSessionFile);
294
- if (loopState.loopSessionFile) failCounts.set(loopState.loopSessionFile, 0);
295
- persistLoopState(pi, {
296
- active: true,
297
- sessionFile: loopState.loopSessionFile,
298
- iteration: loopState.iteration,
299
- maxIterations: loopState.maxIterations,
300
- iterationSummaries: loopState.iterationSummaries,
301
- guardrails: { blockCommands: loopState.guardrails.blockCommands, protectedFiles: loopState.guardrails.protectedFiles },
302
- stopRequested: false,
303
- });
1409
+ if (sessionTarget && !parsed.value) {
1410
+ applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
1411
+ return;
1412
+ }
304
1413
 
305
- pi.sendUserMessage(prompt);
306
- const timeoutMs = fm.timeout * 1000;
307
- let timedOut = false;
308
- let idleError: Error | undefined;
309
- let timer: ReturnType<typeof setTimeout> | undefined;
310
- try {
311
- await Promise.race([
312
- ctx.waitForIdle().catch((e: any) => {
313
- idleError = e instanceof Error ? e : new Error(String(e));
314
- throw e;
315
- }),
316
- new Promise<never>((_, reject) => {
317
- timer = setTimeout(() => {
318
- timedOut = true;
319
- reject(new Error("timeout"));
320
- }, timeoutMs);
321
- }),
322
- ]);
323
- } catch {
324
- // timedOut is set by timer; idleError means waitForIdle failed
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;
325
1420
  }
326
- if (timer) clearTimeout(timer);
327
- if (timedOut) {
328
- ctx.ui.notify(`Iteration ${i} timed out after ${fm.timeout}s, stopping loop`, "warning");
329
- break;
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;
330
1424
  }
331
- if (idleError) {
332
- ctx.ui.notify(`Iteration ${i} agent error: ${idleError.message}, stopping loop`, "error");
333
- break;
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;
334
1428
  }
1429
+ ctx.ui.notify("/ralph-stop expects a task folder or RALPH.md path.", "error");
1430
+ return;
1431
+ }
335
1432
 
336
- const elapsed = Math.round((Date.now() - iterStart) / 1000);
337
- loopState.iterationSummaries.push({ iteration: i, duration: elapsed });
338
- pi.appendEntry("ralph-iteration", { iteration: i, duration: elapsed, ralphPath: loopState.ralphPath });
1433
+ const taskDir = dirname(inspection.ralphPath);
1434
+ if (sessionTarget && sessionTarget.taskDir === taskDir) {
1435
+ applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
1436
+ return;
1437
+ }
339
1438
 
340
- const persistedAfter = readPersistedLoopState(ctx);
341
- if (persistedAfter?.active && persistedAfter.stopRequested) {
342
- loopState.stopRequested = true;
343
- ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
344
- break;
345
- }
1439
+ const registryTarget = activeRegistryEntries().find((entry) => entry.taskDir === taskDir || entry.ralphPath === inspection.ralphPath);
1440
+ if (registryTarget) {
1441
+ applyStopTarget(pi, ctx, materializeRegistryStopTarget(registryTarget), now);
1442
+ return;
1443
+ }
346
1444
 
347
- if (fm.completionPromise) {
348
- const entries = ctx.sessionManager.getEntries();
349
- for (const entry of entries) {
350
- if (entry.type === "message" && entry.message?.role === "assistant") {
351
- const text = entry.message.content?.filter((b: any) => b.type === "text")?.map((b: any) => b.text)?.join("") ?? "";
352
- const match = text.match(/<promise>([^<]+)<\/promise>/);
353
- if (match && fm.completionPromise && match[1].trim() === fm.completionPromise.trim()) {
354
- ctx.ui.notify(`Completion promise matched on iteration ${i}`, "info");
355
- break iterationLoop;
356
- }
357
- }
358
- }
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;
359
1458
  }
360
-
361
- ctx.ui.notify(`Iteration ${i} complete (${elapsed}s)`, "info");
362
1459
  }
363
1460
 
364
- const total = loopState.iterationSummaries.reduce((a, s) => a + s.duration, 0);
365
- ctx.ui.notify(`Ralph loop done: ${loopState.iteration} iterations, ${total}s total`, "info");
366
- } catch (err) {
367
- const message = err instanceof Error ? err.message : String(err);
368
- ctx.ui.notify(`Ralph loop failed: ${message}`, "error");
369
- } finally {
370
- failCounts.clear();
371
- loopState.active = false;
372
- loopState.stopRequested = false;
373
- loopState.loopSessionFile = undefined;
374
- ctx.ui.setStatus("ralph", undefined);
375
- persistLoopState(pi, { active: false });
1461
+ ctx.ui.notify(`No active ralph loop found at ${displayPath(ctx.cwd, inspection.ralphPath)}.`, "warning");
1462
+ return;
376
1463
  }
377
- },
378
- });
379
1464
 
380
- pi.registerCommand("ralph-stop", {
381
- description: "Stop the ralph loop after the current iteration",
382
- handler: async (_args: string, ctx: any) => {
383
- const persisted = readPersistedLoopState(ctx);
384
- if (!persisted?.active) {
385
- if (!loopState.active) {
386
- ctx.ui.notify("No active ralph loop", "warning");
387
- return;
388
- }
389
- loopState.stopRequested = true;
390
- ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
1465
+ if (sessionTarget) {
1466
+ applyStopTarget(pi, ctx, sessionTarget, now, persistedSessionState);
391
1467
  return;
392
1468
  }
393
- loopState.stopRequested = true;
394
- persistLoopState(pi, { ...persisted, stopRequested: true });
395
- ctx.ui.notify("Ralph loop stopping after current iteration…", "info");
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);
396
1481
  },
397
1482
  });
398
1483
  }