@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
@@ -0,0 +1,822 @@
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join } from "node:path";
4
+
5
+ // --- Types ---
6
+
7
+ export type RunnerStatus =
8
+ | "initializing"
9
+ | "running"
10
+ | "complete"
11
+ | "max-iterations"
12
+ | "no-progress-exhaustion"
13
+ | "stopped"
14
+ | "timeout"
15
+ | "error"
16
+ | "cancelled";
17
+
18
+ export type ProgressState = boolean | "unknown";
19
+
20
+ export type CompletionRecord = {
21
+ promiseSeen: boolean;
22
+ durableProgressObserved: boolean;
23
+ gateChecked: boolean;
24
+ gatePassed: boolean;
25
+ gateBlocked: boolean;
26
+ blockingReasons: string[];
27
+ };
28
+
29
+ export type Guardrails = {
30
+ blockCommands: string[];
31
+ protectedFiles: string[];
32
+ };
33
+
34
+ export type IterationRecord = {
35
+ iteration: number;
36
+ status: "running" | "complete" | "timeout" | "error";
37
+ startedAt: string;
38
+ completedAt?: string;
39
+ durationMs?: number;
40
+ progress: ProgressState;
41
+ changedFiles: string[];
42
+ noProgressStreak: number;
43
+ completionPromiseMatched?: boolean;
44
+ completionGate?: { ready: boolean; reasons: string[] };
45
+ completion?: CompletionRecord;
46
+ snapshotTruncated?: boolean;
47
+ snapshotErrorCount?: number;
48
+ loopToken?: string;
49
+ rpcTelemetry?: import("./runner-rpc.ts").RpcTelemetry;
50
+ };
51
+
52
+ export type RunnerStartedEvent = {
53
+ type: "runner.started";
54
+ timestamp: string;
55
+ loopToken: string;
56
+ cwd: string;
57
+ taskDir: string;
58
+ status: "initializing";
59
+ maxIterations: number;
60
+ timeout: number;
61
+ completionPromise?: string;
62
+ guardrails: Guardrails;
63
+ };
64
+
65
+ export type IterationStartedEvent = {
66
+ type: "iteration.started";
67
+ timestamp: string;
68
+ iteration: number;
69
+ loopToken: string;
70
+ status: "running";
71
+ maxIterations: number;
72
+ timeout: number;
73
+ completionPromise?: string;
74
+ };
75
+
76
+ export type DurableProgressObservedEvent = {
77
+ type: "durable.progress.observed";
78
+ timestamp: string;
79
+ iteration: number;
80
+ loopToken: string;
81
+ progress: true;
82
+ changedFiles: string[];
83
+ snapshotTruncated?: boolean;
84
+ snapshotErrorCount?: number;
85
+ };
86
+
87
+ export type DurableProgressMissingEvent = {
88
+ type: "durable.progress.missing";
89
+ timestamp: string;
90
+ iteration: number;
91
+ loopToken: string;
92
+ progress: false;
93
+ changedFiles: string[];
94
+ snapshotTruncated?: boolean;
95
+ snapshotErrorCount?: number;
96
+ };
97
+
98
+ export type DurableProgressUnknownEvent = {
99
+ type: "durable.progress.unknown";
100
+ timestamp: string;
101
+ iteration: number;
102
+ loopToken: string;
103
+ progress: "unknown";
104
+ changedFiles: string[];
105
+ snapshotTruncated?: boolean;
106
+ snapshotErrorCount?: number;
107
+ };
108
+
109
+ export type CompletionPromiseSeenEvent = {
110
+ type: "completion_promise_seen";
111
+ timestamp: string;
112
+ iteration: number;
113
+ loopToken: string;
114
+ completionPromise: string;
115
+ };
116
+
117
+ export type CompletionGateCheckedEvent = {
118
+ type: "completion.gate.checked";
119
+ timestamp: string;
120
+ iteration: number;
121
+ loopToken: string;
122
+ ready: boolean;
123
+ reasons: string[];
124
+ };
125
+
126
+ export type CompletionGatePassedEvent = {
127
+ type: "completion_gate_passed";
128
+ timestamp: string;
129
+ iteration: number;
130
+ loopToken: string;
131
+ ready: true;
132
+ reasons: string[];
133
+ };
134
+
135
+ export type CompletionGateBlockedEvent = {
136
+ type: "completion_gate_blocked";
137
+ timestamp: string;
138
+ iteration: number;
139
+ loopToken: string;
140
+ ready: false;
141
+ reasons: string[];
142
+ };
143
+
144
+ export type IterationCompletedEvent = {
145
+ type: "iteration.completed";
146
+ timestamp: string;
147
+ iteration: number;
148
+ loopToken: string;
149
+ status: "complete" | "timeout" | "error";
150
+ progress: ProgressState;
151
+ changedFiles: string[];
152
+ noProgressStreak: number;
153
+ completionPromiseMatched?: boolean;
154
+ completionGate?: { ready: boolean; reasons: string[] };
155
+ completion?: CompletionRecord;
156
+ snapshotTruncated?: boolean;
157
+ snapshotErrorCount?: number;
158
+ reason?: string;
159
+ };
160
+
161
+ export type RunnerFinishedEvent = {
162
+ type: "runner.finished";
163
+ timestamp: string;
164
+ loopToken: string;
165
+ status: RunnerStatus;
166
+ iterations: number;
167
+ totalDurationMs: number;
168
+ };
169
+
170
+ export type RunnerEvent =
171
+ | RunnerStartedEvent
172
+ | IterationStartedEvent
173
+ | IterationCompletedEvent
174
+ | DurableProgressObservedEvent
175
+ | DurableProgressMissingEvent
176
+ | DurableProgressUnknownEvent
177
+ | CompletionPromiseSeenEvent
178
+ | CompletionGateCheckedEvent
179
+ | CompletionGatePassedEvent
180
+ | CompletionGateBlockedEvent
181
+ | RunnerFinishedEvent;
182
+
183
+ export type RunnerStatusFile = {
184
+ loopToken: string;
185
+ ralphPath: string;
186
+ taskDir: string;
187
+ cwd: string;
188
+ status: RunnerStatus;
189
+ currentIteration: number;
190
+ maxIterations: number;
191
+ timeout: number;
192
+ completionPromise?: string;
193
+ startedAt: string;
194
+ completedAt?: string;
195
+ guardrails: Guardrails;
196
+ };
197
+
198
+ export type ActiveLoopRegistryEntry = {
199
+ taskDir: string;
200
+ ralphPath: string;
201
+ cwd: string;
202
+ loopToken: string;
203
+ status: RunnerStatus;
204
+ currentIteration: number;
205
+ maxIterations: number;
206
+ startedAt: string;
207
+ updatedAt: string;
208
+ stopRequestedAt?: string;
209
+ stopObservedAt?: string;
210
+ };
211
+
212
+ export type TranscriptCommandOutput = {
213
+ name: string;
214
+ output: string;
215
+ };
216
+
217
+ export type IterationTranscriptInput = {
218
+ record: IterationRecord;
219
+ prompt: string;
220
+ commandOutputs: TranscriptCommandOutput[];
221
+ assistantText?: string;
222
+ note?: string;
223
+ };
224
+
225
+ // --- Constants ---
226
+
227
+ const RUNNER_DIR_NAME = ".ralph-runner";
228
+ const TRANSCRIPTS_DIR = "transcripts";
229
+ const STATUS_FILE = "status.json";
230
+ const ITERATIONS_FILE = "iterations.jsonl";
231
+ const EVENTS_FILE = "events.jsonl";
232
+ const STOP_FLAG_FILE = "stop.flag";
233
+ const ACTIVE_LOOP_REGISTRY_DIR = "active-loops";
234
+ const ACTIVE_LOOP_REGISTRY_LEGACY_FILE = "active-loops.json";
235
+ const ACTIVE_LOOP_REGISTRY_FILE_EXTENSION = ".json";
236
+ const ACTIVE_LOOP_REGISTRY_STALE_AFTER_MS = 30 * 60 * 1000;
237
+ const ACTIVE_LOOP_ACTIVE_STATUSES = new Set<RunnerStatus>(["initializing", "running"]);
238
+
239
+ // --- Helper ---
240
+
241
+ function runnerDir(taskDir: string): string {
242
+ return join(taskDir, RUNNER_DIR_NAME);
243
+ }
244
+
245
+ function transcriptDir(taskDir: string): string {
246
+ return join(runnerDir(taskDir), TRANSCRIPTS_DIR);
247
+ }
248
+
249
+ // --- Public API ---
250
+
251
+ export function ensureRunnerDir(taskDir: string): string {
252
+ const dir = runnerDir(taskDir);
253
+ if (!existsSync(dir)) {
254
+ mkdirSync(dir, { recursive: true });
255
+ }
256
+ return dir;
257
+ }
258
+
259
+ export function writeStatusFile(taskDir: string, status: RunnerStatusFile): void {
260
+ const dir = ensureRunnerDir(taskDir);
261
+ writeFileSync(join(dir, STATUS_FILE), JSON.stringify(status, null, 2), "utf8");
262
+ }
263
+
264
+ export function readStatusFile(taskDir: string): RunnerStatusFile | undefined {
265
+ const filePath = join(runnerDir(taskDir), STATUS_FILE);
266
+ if (!existsSync(filePath)) return undefined;
267
+ try {
268
+ const raw = readFileSync(filePath, "utf8");
269
+ return JSON.parse(raw) as RunnerStatusFile;
270
+ } catch {
271
+ return undefined;
272
+ }
273
+ }
274
+
275
+ export function appendIterationRecord(taskDir: string, record: IterationRecord): void {
276
+ const dir = ensureRunnerDir(taskDir);
277
+ const filePath = join(dir, ITERATIONS_FILE);
278
+ const line = JSON.stringify(record) + "\n";
279
+ writeFileSync(filePath, line, { flag: "a", encoding: "utf8" });
280
+ }
281
+
282
+ export function appendRunnerEvent(taskDir: string, event: RunnerEvent): void {
283
+ const dir = ensureRunnerDir(taskDir);
284
+ const filePath = join(dir, EVENTS_FILE);
285
+ writeFileSync(filePath, `${JSON.stringify(event)}\n`, { flag: "a", encoding: "utf8" });
286
+ }
287
+
288
+ function isRecord(value: unknown): value is Record<string, unknown> {
289
+ return typeof value === "object" && value !== null;
290
+ }
291
+
292
+ function isString(value: unknown): value is string {
293
+ return typeof value === "string";
294
+ }
295
+
296
+ function isNumber(value: unknown): value is number {
297
+ return typeof value === "number" && Number.isFinite(value);
298
+ }
299
+
300
+ function isStringArray(value: unknown): value is string[] {
301
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
302
+ }
303
+
304
+ function isProgressState(value: unknown): value is ProgressState {
305
+ return value === true || value === false || value === "unknown";
306
+ }
307
+
308
+ function isGuardrails(value: unknown): value is Guardrails {
309
+ if (!isRecord(value)) return false;
310
+ return isStringArray(value.blockCommands) && isStringArray(value.protectedFiles);
311
+ }
312
+
313
+ function isCompletionRecord(value: unknown): value is CompletionRecord {
314
+ if (!isRecord(value)) return false;
315
+ return (
316
+ typeof value.promiseSeen === "boolean" &&
317
+ typeof value.durableProgressObserved === "boolean" &&
318
+ typeof value.gateChecked === "boolean" &&
319
+ typeof value.gatePassed === "boolean" &&
320
+ typeof value.gateBlocked === "boolean" &&
321
+ isStringArray(value.blockingReasons)
322
+ );
323
+ }
324
+
325
+ function isCompletionGate(value: unknown): value is { ready: boolean; reasons: string[] } {
326
+ if (!isRecord(value)) return false;
327
+ return typeof value.ready === "boolean" && isStringArray(value.reasons);
328
+ }
329
+
330
+ function isIterationCompletedStatus(value: unknown): value is IterationRecord["status"] {
331
+ return value === "complete" || value === "timeout" || value === "error";
332
+ }
333
+
334
+ function isRunnerEvent(value: unknown): value is RunnerEvent {
335
+ if (!isRecord(value) || !isString(value.type) || !isString(value.timestamp)) return false;
336
+
337
+ switch (value.type) {
338
+ case "runner.started":
339
+ return (
340
+ isString(value.loopToken) &&
341
+ isString(value.cwd) &&
342
+ isString(value.taskDir) &&
343
+ value.status === "initializing" &&
344
+ isNumber(value.maxIterations) &&
345
+ Number.isInteger(value.maxIterations) &&
346
+ value.maxIterations > 0 &&
347
+ isNumber(value.timeout) &&
348
+ (value.completionPromise === undefined || isString(value.completionPromise)) &&
349
+ isGuardrails(value.guardrails)
350
+ );
351
+ case "iteration.started":
352
+ return (
353
+ isNumber(value.iteration) &&
354
+ Number.isInteger(value.iteration) &&
355
+ value.iteration > 0 &&
356
+ isString(value.loopToken) &&
357
+ value.status === "running" &&
358
+ isNumber(value.maxIterations) &&
359
+ Number.isInteger(value.maxIterations) &&
360
+ value.maxIterations > 0 &&
361
+ isNumber(value.timeout) &&
362
+ (value.completionPromise === undefined || isString(value.completionPromise))
363
+ );
364
+ case "durable.progress.observed":
365
+ return (
366
+ isNumber(value.iteration) &&
367
+ Number.isInteger(value.iteration) &&
368
+ value.iteration > 0 &&
369
+ isString(value.loopToken) &&
370
+ value.progress === true &&
371
+ isStringArray(value.changedFiles) &&
372
+ (value.snapshotTruncated === undefined || typeof value.snapshotTruncated === "boolean") &&
373
+ (value.snapshotErrorCount === undefined || (isNumber(value.snapshotErrorCount) && Number.isInteger(value.snapshotErrorCount) && value.snapshotErrorCount >= 0))
374
+ );
375
+ case "durable.progress.missing":
376
+ return (
377
+ isNumber(value.iteration) &&
378
+ Number.isInteger(value.iteration) &&
379
+ value.iteration > 0 &&
380
+ isString(value.loopToken) &&
381
+ value.progress === false &&
382
+ isStringArray(value.changedFiles) &&
383
+ (value.snapshotTruncated === undefined || typeof value.snapshotTruncated === "boolean") &&
384
+ (value.snapshotErrorCount === undefined || (isNumber(value.snapshotErrorCount) && Number.isInteger(value.snapshotErrorCount) && value.snapshotErrorCount >= 0))
385
+ );
386
+ case "durable.progress.unknown":
387
+ return (
388
+ isNumber(value.iteration) &&
389
+ Number.isInteger(value.iteration) &&
390
+ value.iteration > 0 &&
391
+ isString(value.loopToken) &&
392
+ value.progress === "unknown" &&
393
+ isStringArray(value.changedFiles) &&
394
+ (value.snapshotTruncated === undefined || typeof value.snapshotTruncated === "boolean") &&
395
+ (value.snapshotErrorCount === undefined || (isNumber(value.snapshotErrorCount) && Number.isInteger(value.snapshotErrorCount) && value.snapshotErrorCount >= 0))
396
+ );
397
+ case "completion_promise_seen":
398
+ return (
399
+ isNumber(value.iteration) &&
400
+ Number.isInteger(value.iteration) &&
401
+ value.iteration > 0 &&
402
+ isString(value.loopToken) &&
403
+ isString(value.completionPromise)
404
+ );
405
+ case "completion.gate.checked":
406
+ return (
407
+ isNumber(value.iteration) &&
408
+ Number.isInteger(value.iteration) &&
409
+ value.iteration > 0 &&
410
+ isString(value.loopToken) &&
411
+ typeof value.ready === "boolean" &&
412
+ isStringArray(value.reasons)
413
+ );
414
+ case "completion_gate_passed":
415
+ return (
416
+ isNumber(value.iteration) &&
417
+ Number.isInteger(value.iteration) &&
418
+ value.iteration > 0 &&
419
+ isString(value.loopToken) &&
420
+ value.ready === true &&
421
+ isStringArray(value.reasons)
422
+ );
423
+ case "completion_gate_blocked":
424
+ return (
425
+ isNumber(value.iteration) &&
426
+ Number.isInteger(value.iteration) &&
427
+ value.iteration > 0 &&
428
+ isString(value.loopToken) &&
429
+ value.ready === false &&
430
+ isStringArray(value.reasons)
431
+ );
432
+ case "iteration.completed":
433
+ return (
434
+ isNumber(value.iteration) &&
435
+ Number.isInteger(value.iteration) &&
436
+ value.iteration > 0 &&
437
+ isString(value.loopToken) &&
438
+ isIterationCompletedStatus(value.status) &&
439
+ isProgressState(value.progress) &&
440
+ isStringArray(value.changedFiles) &&
441
+ isNumber(value.noProgressStreak) &&
442
+ Number.isInteger(value.noProgressStreak) &&
443
+ value.noProgressStreak >= 0 &&
444
+ (value.completionPromiseMatched === undefined || typeof value.completionPromiseMatched === "boolean") &&
445
+ (value.completionGate === undefined || isCompletionGate(value.completionGate)) &&
446
+ (value.completion === undefined || isCompletionRecord(value.completion)) &&
447
+ (value.snapshotTruncated === undefined || typeof value.snapshotTruncated === "boolean") &&
448
+ (value.snapshotErrorCount === undefined || (isNumber(value.snapshotErrorCount) && Number.isInteger(value.snapshotErrorCount) && value.snapshotErrorCount >= 0)) &&
449
+ (value.reason === undefined || isString(value.reason))
450
+ );
451
+ case "runner.finished":
452
+ return (
453
+ isString(value.loopToken) &&
454
+ isRunnerStatus(value.status) &&
455
+ isNumber(value.iterations) &&
456
+ Number.isInteger(value.iterations) &&
457
+ value.iterations >= 0 &&
458
+ isNumber(value.totalDurationMs) &&
459
+ value.totalDurationMs >= 0
460
+ );
461
+ default:
462
+ return false;
463
+ }
464
+ }
465
+
466
+ export function readRunnerEvents(taskDir: string): RunnerEvent[] {
467
+ const filePath = join(runnerDir(taskDir), EVENTS_FILE);
468
+ if (!existsSync(filePath)) return [];
469
+ try {
470
+ const raw = readFileSync(filePath, "utf8");
471
+ return raw
472
+ .split("\n")
473
+ .filter((line) => line.trim())
474
+ .flatMap((line) => {
475
+ try {
476
+ const parsed: unknown = JSON.parse(line);
477
+ return isRunnerEvent(parsed) ? [parsed] : [];
478
+ } catch {
479
+ return [];
480
+ }
481
+ });
482
+ } catch {
483
+ return [];
484
+ }
485
+ }
486
+
487
+ function normalizeTranscriptText(value: string): string {
488
+ return value.replace(/\r\n/g, "\n").trimEnd();
489
+ }
490
+
491
+ function summarizeCompletionRecord(record: IterationRecord): CompletionRecord | undefined {
492
+ if (record.completion) return record.completion;
493
+ if (record.completionPromiseMatched === undefined && record.completionGate === undefined) return undefined;
494
+ return {
495
+ promiseSeen: record.completionPromiseMatched ?? false,
496
+ durableProgressObserved: record.progress === true,
497
+ gateChecked: record.completionPromiseMatched === true && record.progress !== false,
498
+ gatePassed: record.completionGate?.ready === true,
499
+ gateBlocked: record.completionGate?.ready === false,
500
+ blockingReasons: record.completionGate?.reasons ?? [],
501
+ };
502
+ }
503
+
504
+ function completionHeaderLines(record: IterationRecord): string[] {
505
+ const completion = summarizeCompletionRecord(record);
506
+ if (!completion) return [];
507
+ return [
508
+ `- Completion promise seen: ${completion.promiseSeen ? "yes" : "no"}`,
509
+ `- Durable progress observed: ${completion.durableProgressObserved ? "yes" : "no"}`,
510
+ `- Completion gate checked: ${completion.gateChecked ? "yes" : "no"}`,
511
+ `- Completion gate: ${completion.gateChecked ? (completion.gatePassed ? "passed" : completion.gateBlocked ? "blocked" : "pending") : "not checked"}`,
512
+ `- Blocking reasons: ${completion.blockingReasons.length > 0 ? completion.blockingReasons.join("; ") : "none"}`,
513
+ ];
514
+ }
515
+
516
+ function rpcTelemetryHeaderLines(record: IterationRecord): string[] {
517
+ const telemetry = record.rpcTelemetry;
518
+ if (!telemetry) return [];
519
+ return [
520
+ `- RPC telemetry:`,
521
+ ` - Spawned: ${telemetry.spawnedAt}`,
522
+ ...(telemetry.promptSentAt ? [` - Prompt sent: ${telemetry.promptSentAt}`] : []),
523
+ ...(telemetry.firstStdoutEventAt ? [` - First stdout event: ${telemetry.firstStdoutEventAt}`] : []),
524
+ ...(telemetry.lastEventAt && telemetry.lastEventType ? [` - Last stdout event: ${telemetry.lastEventType} at ${telemetry.lastEventAt}`] : []),
525
+ ...(telemetry.exitedAt ? [` - Exited: ${telemetry.exitedAt}`] : []),
526
+ ...(telemetry.timedOutAt ? [` - Timed out: ${telemetry.timedOutAt}`] : []),
527
+ ...(telemetry.exitCode !== undefined ? [` - Exit code: ${String(telemetry.exitCode)}`] : []),
528
+ ...(telemetry.exitSignal !== undefined ? [` - Exit signal: ${String(telemetry.exitSignal)}`] : []),
529
+ ...(telemetry.stderrText ? [` - Stderr: ${telemetry.stderrText.trimEnd()}`] : []),
530
+ ...(telemetry.error ? [` - Error: ${telemetry.error}`] : []),
531
+ ];
532
+ }
533
+
534
+ function transcriptHeaderLines(record: IterationRecord): string[] {
535
+ const lines = [
536
+ `- Status: ${record.status}`,
537
+ `- Started: ${record.startedAt}`,
538
+ `- Progress: ${String(record.progress)}`,
539
+ `- Changed files: ${record.changedFiles.length > 0 ? record.changedFiles.join(", ") : "none"}`,
540
+ `- No-progress streak: ${record.noProgressStreak}`,
541
+ ...completionHeaderLines(record),
542
+ ...rpcTelemetryHeaderLines(record),
543
+ ];
544
+ if (record.completedAt) lines.push(`- Completed: ${record.completedAt}`);
545
+ if (typeof record.durationMs === "number") lines.push(`- Duration: ${Math.round(record.durationMs / 1000)}s`);
546
+ if (record.snapshotTruncated !== undefined) lines.push(`- Snapshot truncated: ${record.snapshotTruncated ? "yes" : "no"}`);
547
+ if (record.snapshotErrorCount !== undefined) lines.push(`- Snapshot errors: ${record.snapshotErrorCount}`);
548
+ return lines;
549
+ }
550
+
551
+ export function writeIterationTranscript(taskDir: string, transcript: IterationTranscriptInput): string {
552
+ const dir = transcriptDir(taskDir);
553
+ mkdirSync(dir, { recursive: true });
554
+ const runToken = transcript.record.loopToken ?? "unknown";
555
+ const filePath = join(dir, `iteration-${String(transcript.record.iteration).padStart(3, "0")}-${runToken}.md`);
556
+ const lines: string[] = [`# Iteration ${transcript.record.iteration}`, "", ...transcriptHeaderLines(transcript.record), "", "## Rendered prompt", "", "```text", normalizeTranscriptText(transcript.prompt), "```", "", "## Command outputs", ""];
557
+
558
+ if (transcript.commandOutputs.length === 0) {
559
+ lines.push("None.");
560
+ } else {
561
+ for (const output of transcript.commandOutputs) {
562
+ lines.push(`### ${output.name}`, "", "```text", normalizeTranscriptText(output.output), "```", "");
563
+ }
564
+ lines.pop();
565
+ }
566
+
567
+ if (transcript.assistantText !== undefined) {
568
+ lines.push("", "## Assistant text", "", "```text", normalizeTranscriptText(transcript.assistantText), "```");
569
+ } else if (transcript.note) {
570
+ lines.push("", "## Outcome", "", transcript.note);
571
+ }
572
+
573
+ writeFileSync(filePath, lines.join("\n") + "\n", "utf8");
574
+ return filePath;
575
+ }
576
+
577
+ export function readIterationRecords(taskDir: string): IterationRecord[] {
578
+ const filePath = join(runnerDir(taskDir), ITERATIONS_FILE);
579
+ if (!existsSync(filePath)) return [];
580
+ try {
581
+ const raw = readFileSync(filePath, "utf8");
582
+ return raw
583
+ .split("\n")
584
+ .filter((line) => line.trim())
585
+ .flatMap((line) => {
586
+ try {
587
+ return [JSON.parse(line) as IterationRecord];
588
+ } catch {
589
+ return [];
590
+ }
591
+ });
592
+ } catch {
593
+ return [];
594
+ }
595
+ }
596
+
597
+ export function createStopSignal(taskDir: string): void {
598
+ const dir = ensureRunnerDir(taskDir);
599
+ writeFileSync(join(dir, STOP_FLAG_FILE), "", "utf8");
600
+ }
601
+
602
+ export function checkStopSignal(taskDir: string): boolean {
603
+ return existsSync(join(runnerDir(taskDir), STOP_FLAG_FILE));
604
+ }
605
+
606
+ export function clearStopSignal(taskDir: string): void {
607
+ const filePath = join(runnerDir(taskDir), STOP_FLAG_FILE);
608
+ if (existsSync(filePath)) {
609
+ rmSync(filePath, { force: true });
610
+ }
611
+ }
612
+
613
+ export function clearRunnerDir(taskDir: string): void {
614
+ const dir = runnerDir(taskDir);
615
+ if (existsSync(dir)) {
616
+ rmSync(dir, { recursive: true, force: true });
617
+ }
618
+ }
619
+
620
+ function activeLoopRegistryDir(cwd: string): string {
621
+ return join(runnerDir(cwd), ACTIVE_LOOP_REGISTRY_DIR);
622
+ }
623
+
624
+ function activeLoopRegistryEntryPath(cwd: string, taskDir: string): string {
625
+ return join(activeLoopRegistryDir(cwd), `${createHash("sha256").update(taskDir).digest("hex")}${ACTIVE_LOOP_REGISTRY_FILE_EXTENSION}`);
626
+ }
627
+
628
+ function ensureActiveLoopRegistryDir(cwd: string): string {
629
+ const dir = activeLoopRegistryDir(cwd);
630
+ if (!existsSync(dir)) {
631
+ mkdirSync(dir, { recursive: true });
632
+ }
633
+ return dir;
634
+ }
635
+
636
+ function writeFileAtomic(filePath: string, contents: string): void {
637
+ const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
638
+ writeFileSync(tempPath, contents, "utf8");
639
+ renameSync(tempPath, filePath);
640
+ }
641
+
642
+ function parseIsoTimestamp(raw: unknown): number | undefined {
643
+ if (typeof raw !== "string") return undefined;
644
+ const parsed = Date.parse(raw);
645
+ return Number.isFinite(parsed) ? parsed : undefined;
646
+ }
647
+
648
+ function isActiveLoopRegistryEntryStale(entry: ActiveLoopRegistryEntry): boolean {
649
+ const updatedAtMs = parseIsoTimestamp(entry.updatedAt);
650
+ return updatedAtMs === undefined || Date.now() - updatedAtMs > ACTIVE_LOOP_REGISTRY_STALE_AFTER_MS;
651
+ }
652
+
653
+ function isRunnerStatus(value: unknown): value is RunnerStatus {
654
+ return (
655
+ value === "initializing" ||
656
+ value === "running" ||
657
+ value === "complete" ||
658
+ value === "max-iterations" ||
659
+ value === "no-progress-exhaustion" ||
660
+ value === "stopped" ||
661
+ value === "timeout" ||
662
+ value === "error" ||
663
+ value === "cancelled"
664
+ );
665
+ }
666
+
667
+ function normalizeActiveLoopRegistryEntry(entry: unknown): ActiveLoopRegistryEntry | undefined {
668
+ if (!entry || typeof entry !== "object") return undefined;
669
+ const candidate = entry as Record<string, unknown>;
670
+ if (
671
+ typeof candidate.taskDir !== "string" ||
672
+ candidate.taskDir.length === 0 ||
673
+ typeof candidate.ralphPath !== "string" ||
674
+ candidate.ralphPath.length === 0 ||
675
+ typeof candidate.cwd !== "string" ||
676
+ candidate.cwd.length === 0 ||
677
+ typeof candidate.loopToken !== "string" ||
678
+ candidate.loopToken.length === 0 ||
679
+ !isRunnerStatus(candidate.status) ||
680
+ typeof candidate.currentIteration !== "number" ||
681
+ !Number.isInteger(candidate.currentIteration) ||
682
+ candidate.currentIteration < 0 ||
683
+ typeof candidate.maxIterations !== "number" ||
684
+ !Number.isInteger(candidate.maxIterations) ||
685
+ candidate.maxIterations <= 0 ||
686
+ typeof candidate.startedAt !== "string" ||
687
+ candidate.startedAt.length === 0 ||
688
+ typeof candidate.updatedAt !== "string" ||
689
+ candidate.updatedAt.length === 0
690
+ ) {
691
+ return undefined;
692
+ }
693
+
694
+ const stopRequestedAt = candidate.stopRequestedAt;
695
+ const stopObservedAt = candidate.stopObservedAt;
696
+ if ((stopRequestedAt !== undefined && typeof stopRequestedAt !== "string") || (stopObservedAt !== undefined && typeof stopObservedAt !== "string")) {
697
+ return undefined;
698
+ }
699
+
700
+ const normalized: ActiveLoopRegistryEntry = {
701
+ taskDir: candidate.taskDir,
702
+ ralphPath: candidate.ralphPath,
703
+ cwd: candidate.cwd,
704
+ loopToken: candidate.loopToken,
705
+ status: candidate.status,
706
+ currentIteration: candidate.currentIteration,
707
+ maxIterations: candidate.maxIterations,
708
+ startedAt: candidate.startedAt,
709
+ updatedAt: candidate.updatedAt,
710
+ };
711
+ if (typeof stopRequestedAt === "string") normalized.stopRequestedAt = stopRequestedAt;
712
+ if (typeof stopObservedAt === "string") normalized.stopObservedAt = stopObservedAt;
713
+ return normalized;
714
+ }
715
+
716
+ function readActiveLoopRegistryEntryFile(filePath: string): ActiveLoopRegistryEntry | undefined {
717
+ if (!existsSync(filePath)) return undefined;
718
+ try {
719
+ const raw = readFileSync(filePath, "utf8");
720
+ const parsed: unknown = JSON.parse(raw);
721
+ const entry = normalizeActiveLoopRegistryEntry(parsed);
722
+ if (!entry) {
723
+ rmSync(filePath, { force: true });
724
+ return undefined;
725
+ }
726
+ if (isActiveLoopRegistryEntryStale(entry)) {
727
+ rmSync(filePath, { force: true });
728
+ return undefined;
729
+ }
730
+ return entry;
731
+ } catch {
732
+ rmSync(filePath, { force: true });
733
+ return undefined;
734
+ }
735
+ }
736
+
737
+ function readLegacyActiveLoopRegistryEntries(cwd: string): ActiveLoopRegistryEntry[] {
738
+ const filePath = join(runnerDir(cwd), ACTIVE_LOOP_REGISTRY_LEGACY_FILE);
739
+ if (!existsSync(filePath)) return [];
740
+ try {
741
+ const raw = readFileSync(filePath, "utf8");
742
+ const parsed: unknown = JSON.parse(raw);
743
+ if (!Array.isArray(parsed)) return [];
744
+
745
+ const normalizedEntries = parsed.map(normalizeActiveLoopRegistryEntry).filter((entry): entry is ActiveLoopRegistryEntry => entry !== undefined);
746
+ const freshEntries = normalizedEntries.filter((entry) => !isActiveLoopRegistryEntryStale(entry));
747
+
748
+ if (freshEntries.length !== normalizedEntries.length) {
749
+ if (freshEntries.length > 0) {
750
+ writeFileSync(filePath, `${JSON.stringify(freshEntries, null, 2)}\n`, "utf8");
751
+ } else {
752
+ rmSync(filePath, { force: true });
753
+ }
754
+ }
755
+
756
+ return freshEntries;
757
+ } catch {
758
+ return [];
759
+ }
760
+ }
761
+
762
+ function readRawActiveLoopRegistryEntries(cwd: string): ActiveLoopRegistryEntry[] {
763
+ const dir = activeLoopRegistryDir(cwd);
764
+ const entriesByTaskDir = new Map<string, ActiveLoopRegistryEntry>();
765
+
766
+ for (const entry of readLegacyActiveLoopRegistryEntries(cwd)) {
767
+ entriesByTaskDir.set(entry.taskDir, entry);
768
+ }
769
+
770
+ if (existsSync(dir)) {
771
+ for (const dirent of readdirSync(dir, { withFileTypes: true })) {
772
+ if (!dirent.isFile() || !dirent.name.endsWith(ACTIVE_LOOP_REGISTRY_FILE_EXTENSION)) continue;
773
+ const entry = readActiveLoopRegistryEntryFile(join(dir, dirent.name));
774
+ if (entry) entriesByTaskDir.set(entry.taskDir, entry);
775
+ }
776
+ }
777
+
778
+ return [...entriesByTaskDir.values()].sort((left, right) => left.taskDir.localeCompare(right.taskDir));
779
+ }
780
+
781
+ function readActiveLoopRegistryEntry(cwd: string, taskDir: string): ActiveLoopRegistryEntry | undefined {
782
+ return readRawActiveLoopRegistryEntries(cwd).find((entry) => entry.taskDir === taskDir);
783
+ }
784
+
785
+ export function readActiveLoopRegistry(cwd: string): ActiveLoopRegistryEntry[] {
786
+ return readRawActiveLoopRegistryEntries(cwd);
787
+ }
788
+
789
+ export function listActiveLoopRegistryEntries(cwd: string): ActiveLoopRegistryEntry[] {
790
+ return readRawActiveLoopRegistryEntries(cwd).filter((entry) => ACTIVE_LOOP_ACTIVE_STATUSES.has(entry.status));
791
+ }
792
+
793
+ export function writeActiveLoopRegistryEntry(cwd: string, entry: ActiveLoopRegistryEntry): ActiveLoopRegistryEntry[] {
794
+ ensureActiveLoopRegistryDir(cwd);
795
+ writeFileAtomic(activeLoopRegistryEntryPath(cwd, entry.taskDir), `${JSON.stringify(entry, null, 2)}\n`);
796
+ return readRawActiveLoopRegistryEntries(cwd);
797
+ }
798
+
799
+ export function recordActiveLoopStopRequest(cwd: string, taskDir: string, requestedAt: string): ActiveLoopRegistryEntry | undefined {
800
+ const current = readActiveLoopRegistryEntry(cwd, taskDir);
801
+ if (!current) return undefined;
802
+ const updated: ActiveLoopRegistryEntry = {
803
+ ...current,
804
+ stopRequestedAt: requestedAt,
805
+ updatedAt: requestedAt,
806
+ };
807
+ writeActiveLoopRegistryEntry(cwd, updated);
808
+ return updated;
809
+ }
810
+
811
+ export function recordActiveLoopStopObservation(cwd: string, taskDir: string, observedAt: string): ActiveLoopRegistryEntry | undefined {
812
+ const current = readActiveLoopRegistryEntry(cwd, taskDir);
813
+ if (!current) return undefined;
814
+ const updated: ActiveLoopRegistryEntry = {
815
+ ...current,
816
+ status: "stopped",
817
+ stopObservedAt: observedAt,
818
+ updatedAt: observedAt,
819
+ };
820
+ writeActiveLoopRegistryEntry(cwd, updated);
821
+ return updated;
822
+ }