@lnilluv/pi-ralph-loop 0.1.3 → 0.1.4-dev.1

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