@lnilluv/pi-ralph-loop 0.3.0 → 1.0.0

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