@lnilluv/pi-ralph-loop 0.3.0 → 1.1.0

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