@lnilluv/pi-ralph-loop 0.3.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/.github/workflows/release.yml +8 -39
  2. package/README.md +53 -160
  3. package/package.json +2 -2
  4. package/scripts/version-helper.ts +210 -0
  5. package/src/index.ts +1388 -187
  6. package/src/ralph-draft-context.ts +618 -0
  7. package/src/ralph-draft-llm.ts +297 -0
  8. package/src/ralph-draft.ts +33 -0
  9. package/src/ralph.ts +924 -102
  10. package/src/runner-rpc.ts +466 -0
  11. package/src/runner-state.ts +839 -0
  12. package/src/runner.ts +1042 -0
  13. package/src/secret-paths.ts +66 -0
  14. package/src/shims.d.ts +0 -3
  15. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  16. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  17. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  18. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  19. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  20. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  21. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  22. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  23. package/tests/fixtures/parity/research/RALPH.md +45 -0
  24. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  25. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  26. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  27. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  28. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  29. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  30. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  31. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  32. package/tests/index.test.ts +3801 -0
  33. package/tests/parity/README.md +9 -0
  34. package/tests/parity/harness.py +526 -0
  35. package/tests/parity-harness.test.ts +42 -0
  36. package/tests/parity-research-fixture.test.ts +34 -0
  37. package/tests/ralph-draft-context.test.ts +672 -0
  38. package/tests/ralph-draft-llm.test.ts +434 -0
  39. package/tests/ralph-draft.test.ts +168 -0
  40. package/tests/ralph.test.ts +1413 -19
  41. package/tests/runner-event-contract.test.ts +235 -0
  42. package/tests/runner-rpc.test.ts +446 -0
  43. package/tests/runner-state.test.ts +581 -0
  44. package/tests/runner.test.ts +1552 -0
  45. package/tests/secret-paths.test.ts +55 -0
  46. package/tests/version-helper.test.ts +75 -0
@@ -0,0 +1,466 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // --- Types ---
6
+
7
+ export type RpcEvent = {
8
+ type: string;
9
+ [key: string]: unknown;
10
+ };
11
+
12
+ export type RpcSubprocessConfig = {
13
+ prompt: string;
14
+ cwd: string;
15
+ timeoutMs: number;
16
+ /** Override the spawn command for testing. Defaults to "pi" */
17
+ spawnCommand?: string;
18
+ /** Override spawn args for testing. Defaults to ["--mode", "rpc", "--no-session"] */
19
+ spawnArgs?: string[];
20
+ /** Additional environment variables for the subprocess */
21
+ env?: Record<string, string>;
22
+ /** Model selection for RPC subprocess. Format: "provider/modelId" or "provider/modelId:thinkingLevel"
23
+ * Examples: "anthropic/claude-sonnet-4-20250514" or "openai-codex/gpt-5.4-mini:high"
24
+ * Parsed into set_model + set_thinking_level commands.
25
+ */
26
+ modelPattern?: string;
27
+ /** Explicit provider for set_model (overrides modelPattern provider) */
28
+ provider?: string;
29
+ /** Explicit modelId for set_model (overrides modelPattern modelId) */
30
+ modelId?: string;
31
+ /** Thinking level for set_thinking_level: "off", "minimal", "low", "medium", "high", "xhigh".
32
+ * Also parsed from modelPattern suffix (e.g. ":high").
33
+ */
34
+ thinkingLevel?: string;
35
+ /** Callback for observing events as they stream */
36
+ onEvent?: (event: RpcEvent) => void;
37
+ signal?: AbortSignal;
38
+ };
39
+
40
+ export type RpcTelemetry = {
41
+ spawnedAt: string;
42
+ promptSentAt?: string;
43
+ firstStdoutEventAt?: string;
44
+ lastEventAt?: string;
45
+ lastEventType?: string;
46
+ exitedAt?: string;
47
+ timedOutAt?: string;
48
+ exitCode?: number | null;
49
+ exitSignal?: NodeJS.Signals | null;
50
+ stderrText?: string;
51
+ error?: string;
52
+ };
53
+
54
+ export type RpcSubprocessResult = {
55
+ success: boolean;
56
+ lastAssistantText: string;
57
+ agentEndMessages: unknown[];
58
+ timedOut: boolean;
59
+ cancelled?: boolean;
60
+ error?: string;
61
+ telemetry: RpcTelemetry;
62
+ };
63
+
64
+ export type RpcPromptResult = {
65
+ success: boolean;
66
+ error?: string;
67
+ };
68
+
69
+ // --- RPC JSONL Parsing ---
70
+
71
+ export function parseRpcEvent(line: string): RpcEvent {
72
+ const trimmed = line.trim();
73
+ if (!trimmed) return { type: "empty" };
74
+ try {
75
+ const parsed: unknown = JSON.parse(trimmed);
76
+ if (typeof parsed === "object" && parsed !== null && "type" in parsed) {
77
+ return parsed as RpcEvent;
78
+ }
79
+ return { type: "unknown" };
80
+ } catch {
81
+ return { type: "unknown" };
82
+ }
83
+ }
84
+
85
+ function extractAssistantText(messages: unknown[]): string {
86
+ if (!Array.isArray(messages)) return "";
87
+ const texts: string[] = [];
88
+ for (const msg of messages) {
89
+ if (
90
+ typeof msg === "object" &&
91
+ msg !== null &&
92
+ "role" in msg &&
93
+ (msg as Record<string, unknown>).role === "assistant" &&
94
+ "content" in msg
95
+ ) {
96
+ const content = (msg as Record<string, unknown>).content;
97
+ if (Array.isArray(content)) {
98
+ for (const block of content) {
99
+ if (
100
+ typeof block === "object" &&
101
+ block !== null &&
102
+ "type" in block &&
103
+ (block as Record<string, unknown>).type === "text" &&
104
+ "text" in block
105
+ ) {
106
+ texts.push(String((block as Record<string, unknown>).text));
107
+ }
108
+ }
109
+ } else if (typeof content === "string") {
110
+ texts.push(content);
111
+ }
112
+ }
113
+ }
114
+ return texts.join("");
115
+ }
116
+
117
+ // --- RPC Subprocess Execution ---
118
+
119
+ export async function runRpcIteration(config: RpcSubprocessConfig): Promise<RpcSubprocessResult> {
120
+ const {
121
+ prompt,
122
+ cwd,
123
+ timeoutMs,
124
+ spawnCommand = "pi",
125
+ spawnArgs,
126
+ env,
127
+ modelPattern,
128
+ provider: explicitProvider,
129
+ modelId: explicitModelId,
130
+ onEvent,
131
+ signal,
132
+ } = config;
133
+
134
+ // Parse modelPattern ("provider/modelId" or "provider/modelId:thinking") into provider and modelId
135
+ let modelProvider = explicitProvider;
136
+ let modelModelId = explicitModelId;
137
+ let thinkingLevel = config.thinkingLevel;
138
+ if (modelPattern && !explicitModelId) {
139
+ // Extract thinking level suffix (e.g. ":high")
140
+ const lastColonIdx = modelPattern.lastIndexOf(":");
141
+ const validThinkingLevels = new Set(["off", "minimal", "low", "medium", "high", "xhigh"]);
142
+ let patternWithoutThinking = modelPattern;
143
+ if (lastColonIdx > 0 && validThinkingLevels.has(modelPattern.slice(lastColonIdx + 1))) {
144
+ thinkingLevel = modelPattern.slice(lastColonIdx + 1);
145
+ patternWithoutThinking = modelPattern.slice(0, lastColonIdx);
146
+ }
147
+
148
+ const slashIdx = patternWithoutThinking.indexOf("/");
149
+ if (slashIdx > 0) {
150
+ modelProvider = patternWithoutThinking.slice(0, slashIdx);
151
+ modelModelId = patternWithoutThinking.slice(slashIdx + 1);
152
+ }
153
+ }
154
+
155
+ const extensionPath = fileURLToPath(new URL("./index.ts", import.meta.url));
156
+ const args = spawnArgs ?? ["--mode", "rpc", "--no-session", "-e", extensionPath];
157
+ const subprocessEnv = { ...process.env, ...env };
158
+ const telemetry: RpcTelemetry = {
159
+ spawnedAt: new Date().toISOString(),
160
+ };
161
+
162
+ let childProcess: ReturnType<typeof spawn>;
163
+ let stderrText = "";
164
+ const buildResult = (result: Omit<RpcSubprocessResult, "telemetry">): RpcSubprocessResult => ({
165
+ ...result,
166
+ telemetry: {
167
+ ...telemetry,
168
+ ...(stderrText ? { stderrText } : {}),
169
+ },
170
+ });
171
+
172
+ try {
173
+ childProcess = spawn(spawnCommand, args, {
174
+ cwd,
175
+ env: subprocessEnv,
176
+ stdio: ["pipe", "pipe", "pipe"],
177
+ });
178
+ } catch (err) {
179
+ telemetry.error = err instanceof Error ? err.message : String(err);
180
+ return buildResult({
181
+ success: false,
182
+ lastAssistantText: "",
183
+ agentEndMessages: [],
184
+ timedOut: false,
185
+ error: telemetry.error,
186
+ });
187
+ }
188
+
189
+ return new Promise<RpcSubprocessResult>((resolve) => {
190
+ let settled = false;
191
+ let lastAssistantText = "";
192
+ let agentEndMessages: unknown[] = [];
193
+ let promptSent = false;
194
+ let promptAcknowledged = false;
195
+ let sawAgentEnd = false;
196
+ let modelSetAcknowledged = !(modelProvider && modelModelId); // true if no set_model needed
197
+ let thinkingLevelAcknowledged = !thinkingLevel; // true if no set_thinking_level needed
198
+
199
+ const nowIso = () => new Date().toISOString();
200
+ const markStdoutEvent = (eventType: string) => {
201
+ const observedAt = nowIso();
202
+ if (!telemetry.firstStdoutEventAt) telemetry.firstStdoutEventAt = observedAt;
203
+ telemetry.lastEventAt = observedAt;
204
+ telemetry.lastEventType = eventType;
205
+ };
206
+
207
+ let timeout: ReturnType<typeof setTimeout> | undefined;
208
+
209
+ const onAbort = () => {
210
+ if (settled) return;
211
+ settled = true;
212
+ telemetry.error = "cancelled";
213
+ try {
214
+ childProcess.kill("SIGKILL");
215
+ } catch {
216
+ // process may already be dead
217
+ }
218
+ clearTimeout(timeout);
219
+ resolve(buildResult({
220
+ success: false,
221
+ lastAssistantText,
222
+ agentEndMessages,
223
+ timedOut: false,
224
+ cancelled: true,
225
+ error: "cancelled",
226
+ }));
227
+ };
228
+
229
+ if (signal?.aborted) {
230
+ onAbort();
231
+ return;
232
+ }
233
+ signal?.addEventListener("abort", onAbort, { once: true });
234
+
235
+ timeout = setTimeout(() => {
236
+ if (settled) return;
237
+ settled = true;
238
+ telemetry.timedOutAt = nowIso();
239
+ try {
240
+ childProcess.kill("SIGKILL");
241
+ } catch {
242
+ // process may already be dead
243
+ }
244
+ resolve(buildResult({
245
+ success: false,
246
+ lastAssistantText,
247
+ agentEndMessages,
248
+ timedOut: true,
249
+ }));
250
+ }, timeoutMs);
251
+
252
+ const endStdin = () => {
253
+ // Close stdin so the subprocess knows no more commands are coming
254
+ try {
255
+ childProcess.stdin?.end();
256
+ } catch {
257
+ // already closed
258
+ }
259
+ };
260
+
261
+ const cleanup = () => {
262
+ clearTimeout(timeout);
263
+ endStdin();
264
+ signal?.removeEventListener("abort", onAbort);
265
+ };
266
+
267
+ const settle = (result: RpcSubprocessResult) => {
268
+ if (settled) return;
269
+ settled = true;
270
+ cleanup();
271
+ // Kill subprocess if still running
272
+ try {
273
+ childProcess.kill();
274
+ } catch {
275
+ // already dead
276
+ }
277
+ resolve(result);
278
+ };
279
+
280
+ // Set up stderr collection
281
+ childProcess.stderr?.on("data", (data: Buffer) => {
282
+ stderrText += data.toString("utf8");
283
+ });
284
+
285
+ // Set up stdout line reader
286
+ let stdoutBuffer = "";
287
+ childProcess.stdout?.on("data", (data: Buffer) => {
288
+ stdoutBuffer += data.toString("utf8");
289
+
290
+ // Parse complete lines
291
+ let newlineIndex: number;
292
+ while ((newlineIndex = stdoutBuffer.indexOf("\n")) !== -1) {
293
+ const line = stdoutBuffer.slice(0, newlineIndex);
294
+ stdoutBuffer = stdoutBuffer.slice(newlineIndex + 1);
295
+
296
+ // Handle \r\n
297
+ const trimmedLine = line.endsWith("\r") ? line.slice(0, -1) : line;
298
+ if (!trimmedLine) continue;
299
+
300
+ const event = parseRpcEvent(trimmedLine);
301
+ markStdoutEvent(event.type);
302
+ onEvent?.(event);
303
+
304
+ if (event.type === "response") {
305
+ const resp = event as { command?: string; success?: boolean };
306
+ if (resp.command === "set_model" && resp.success === true) {
307
+ modelSetAcknowledged = true;
308
+ }
309
+ if (resp.command === "set_thinking_level" && resp.success === true) {
310
+ thinkingLevelAcknowledged = true;
311
+ }
312
+ if (resp.command === "prompt" && resp.success === true) {
313
+ promptAcknowledged = true;
314
+ }
315
+ continue;
316
+ }
317
+
318
+ if (event.type === "agent_end") {
319
+ const endEvent = event as { messages?: unknown[] };
320
+ sawAgentEnd = true;
321
+ agentEndMessages = Array.isArray(endEvent.messages) ? endEvent.messages : [];
322
+ lastAssistantText = extractAssistantText(agentEndMessages);
323
+ endStdin();
324
+ continue;
325
+ }
326
+ }
327
+ });
328
+
329
+ childProcess.on("error", (err: Error) => {
330
+ telemetry.error = err.message;
331
+ settle(buildResult({
332
+ success: false,
333
+ lastAssistantText,
334
+ agentEndMessages,
335
+ timedOut: false,
336
+ error: err.message,
337
+ }));
338
+ });
339
+ childProcess.stdin?.on("error", (err: Error & { code?: string }) => {
340
+ if (settled) return;
341
+ const error = err.code === "EPIPE" ? "Subprocess closed stdin before prompt could be sent" : err.message;
342
+ telemetry.error = error;
343
+ settle(buildResult({
344
+ success: false,
345
+ lastAssistantText,
346
+ agentEndMessages,
347
+ timedOut: false,
348
+ error,
349
+ }));
350
+ });
351
+
352
+ childProcess.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
353
+ if (settled) return;
354
+ telemetry.exitedAt = nowIso();
355
+ telemetry.exitCode = code;
356
+ telemetry.exitSignal = signal;
357
+
358
+ const closeError =
359
+ code !== 0 && code !== null
360
+ ? `Subprocess exited with code ${code}${stderrText ? `: ${stderrText.slice(0, 200)}` : ""}`
361
+ : signal
362
+ ? `Subprocess exited due to signal ${signal}${stderrText ? `: ${stderrText.slice(0, 200)}` : ""}`
363
+ : sawAgentEnd
364
+ ? undefined
365
+ : "Subprocess exited without sending agent_end";
366
+ if (closeError) telemetry.error = closeError;
367
+
368
+ settle(buildResult({
369
+ success: sawAgentEnd && code === 0 && signal === null,
370
+ lastAssistantText,
371
+ agentEndMessages,
372
+ timedOut: false,
373
+ error: closeError,
374
+ }));
375
+ });
376
+
377
+ // Send set_model command if provider/model are specified
378
+ if (modelProvider && modelModelId) {
379
+ const setModelCommand = JSON.stringify({
380
+ type: "set_model",
381
+ provider: modelProvider,
382
+ modelId: modelModelId,
383
+ });
384
+ try {
385
+ childProcess.stdin?.write(setModelCommand + "\n");
386
+ } catch (err) {
387
+ const error = `Failed to send set_model command: ${err instanceof Error ? err.message : String(err)}`;
388
+ telemetry.error = error;
389
+ settle(buildResult({
390
+ success: false,
391
+ lastAssistantText,
392
+ agentEndMessages,
393
+ timedOut: false,
394
+ error,
395
+ }));
396
+ return;
397
+ }
398
+ }
399
+
400
+ // Send set_thinking_level if specified
401
+ if (thinkingLevel) {
402
+ const setThinkingCommand = JSON.stringify({
403
+ type: "set_thinking_level",
404
+ level: thinkingLevel,
405
+ });
406
+ try {
407
+ childProcess.stdin?.write(setThinkingCommand + "\n");
408
+ } catch (err) {
409
+ const error = `Failed to send set_thinking_level command: ${err instanceof Error ? err.message : String(err)}`;
410
+ telemetry.error = error;
411
+ settle(buildResult({
412
+ success: false,
413
+ lastAssistantText,
414
+ agentEndMessages,
415
+ timedOut: false,
416
+ error,
417
+ }));
418
+ return;
419
+ }
420
+ }
421
+
422
+ // Wait for set_model acknowledgment before sending prompt
423
+ const sendPrompt = () => {
424
+ // Send the prompt command
425
+ const promptCommand = JSON.stringify({
426
+ type: "prompt",
427
+ id: `ralph-${randomUUID()}`,
428
+ message: prompt,
429
+ });
430
+
431
+ try {
432
+ telemetry.promptSentAt = telemetry.promptSentAt ?? nowIso();
433
+ childProcess.stdin?.write(promptCommand + "\n");
434
+ promptSent = true;
435
+ } catch (err) {
436
+ const error = err instanceof Error ? err.message : String(err);
437
+ telemetry.error = error;
438
+ settle(buildResult({
439
+ success: false,
440
+ lastAssistantText,
441
+ agentEndMessages,
442
+ timedOut: false,
443
+ error,
444
+ }));
445
+ }
446
+ };
447
+
448
+ if (modelSetAcknowledged && thinkingLevelAcknowledged) {
449
+ sendPrompt();
450
+ } else {
451
+ const waitForAcknowledgements = async () => {
452
+ const deadline = Date.now() + 5000;
453
+ while (!settled && !promptSent && Date.now() < deadline) {
454
+ if (modelSetAcknowledged && thinkingLevelAcknowledged) break;
455
+ await new Promise<void>((resolveWait) => setTimeout(resolveWait, 50));
456
+ }
457
+ };
458
+
459
+ void waitForAcknowledgements().then(() => {
460
+ if (!settled && !promptSent) {
461
+ sendPrompt();
462
+ }
463
+ });
464
+ }
465
+ });
466
+ }