@lnilluv/pi-ralph-loop 0.1.1 → 0.1.4-dev.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.
@@ -0,0 +1,66 @@
1
+ import { basename, normalize, relative, resolve } from "node:path";
2
+ import { minimatch } from "minimatch";
3
+
4
+ const SECRET_PATH_SEGMENTS = new Set([".aws", ".ssh", "secrets", "credentials", "ops-secrets", "credentials-prod"]);
5
+ const SECRET_BASENAMES = new Set([".npmrc", ".pypirc", ".netrc"]);
6
+ const SECRET_SUFFIXES = [".pem", ".key", ".asc"];
7
+ export const SECRET_PATH_POLICY_TOKEN = "policy:secret-bearing-paths";
8
+
9
+ function toPosixPath(value: string): string {
10
+ return value.replace(/\\/g, "/");
11
+ }
12
+
13
+ function normalizePath(value: string): string {
14
+ return toPosixPath(normalize(value));
15
+ }
16
+
17
+ function candidatePaths(path: string, cwd?: string): string[] {
18
+ const candidates = new Set<string>();
19
+ const normalizedRaw = normalizePath(path);
20
+ if (normalizedRaw) candidates.add(normalizedRaw);
21
+
22
+ const absolutePath = normalizePath(resolve(cwd ?? process.cwd(), path));
23
+ if (absolutePath) candidates.add(absolutePath);
24
+
25
+ if (cwd) {
26
+ const repoRelative = toPosixPath(relative(cwd, absolutePath));
27
+ if (repoRelative && !repoRelative.startsWith("..")) candidates.add(repoRelative);
28
+ }
29
+
30
+ return [...candidates];
31
+ }
32
+
33
+ function isSecretPathCandidate(candidatePath: string): boolean {
34
+ const normalizedPath = toPosixPath(candidatePath).toLowerCase();
35
+ if (!normalizedPath || normalizedPath.startsWith("..")) return false;
36
+
37
+ const segments = normalizedPath.split("/").filter(Boolean);
38
+ const normalizedName = basename(normalizedPath);
39
+ return (
40
+ normalizedName.startsWith(".env") ||
41
+ SECRET_BASENAMES.has(normalizedName) ||
42
+ SECRET_SUFFIXES.some((suffix) => normalizedName.endsWith(suffix)) ||
43
+ segments.some((segment) => SECRET_PATH_SEGMENTS.has(segment))
44
+ );
45
+ }
46
+
47
+ export function isSecretBearingPath(relativePath: string): boolean {
48
+ return isSecretPathCandidate(normalizePath(relativePath));
49
+ }
50
+
51
+ export function matchesProtectedPath(relativePath: string, protectedFiles: string[], cwd?: string): boolean {
52
+ const candidates = candidatePaths(relativePath, cwd);
53
+ return protectedFiles.some((pattern) =>
54
+ candidates.some((candidate) =>
55
+ pattern === SECRET_PATH_POLICY_TOKEN ? isSecretPathCandidate(candidate) : minimatch(candidate, pattern, { matchBase: true }),
56
+ ),
57
+ );
58
+ }
59
+
60
+ export function isSecretBearingTopLevelName(name: string): boolean {
61
+ return isSecretBearingPath(name);
62
+ }
63
+
64
+ export function filterSecretBearingTopLevelNames(names: string[]): string[] {
65
+ return names.filter((name) => !isSecretBearingTopLevelName(name));
66
+ }
package/src/shims.d.ts ADDED
@@ -0,0 +1,23 @@
1
+ declare module "yaml" {
2
+ export function parse(input: string): any;
3
+ }
4
+
5
+ declare module "minimatch" {
6
+ export function minimatch(path: string, pattern: string, options?: any): boolean;
7
+ }
8
+
9
+ declare module "node:fs" {
10
+ export function readFileSync(path: string, encoding: string): string;
11
+ export function existsSync(path: string): boolean;
12
+ }
13
+
14
+ declare module "node:path" {
15
+ export function resolve(...paths: string[]): string;
16
+ export function join(...paths: string[]): string;
17
+ export function dirname(path: string): string;
18
+ export function basename(path: string): string;
19
+ }
20
+
21
+ declare module "@mariozechner/pi-coding-agent" {
22
+ export type ExtensionAPI = any;
23
+ }
@@ -0,0 +1,464 @@
1
+ import assert from "node:assert/strict";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import registerRalphCommands from "../src/index.ts";
7
+ import { SECRET_PATH_POLICY_TOKEN } from "../src/secret-paths.ts";
8
+ import { generateDraft, slugifyTask, type DraftPlan, type DraftTarget } from "../src/ralph.ts";
9
+ import type { StrengthenDraftRuntime } from "../src/ralph-draft-llm.ts";
10
+
11
+ function createTempDir(): string {
12
+ return mkdtempSync(join(tmpdir(), "pi-ralph-loop-index-"));
13
+ }
14
+
15
+ function createTarget(cwd: string, task: string): DraftTarget {
16
+ const slug = slugifyTask(task);
17
+ return {
18
+ slug,
19
+ dirPath: join(cwd, slug),
20
+ ralphPath: join(cwd, slug, "RALPH.md"),
21
+ };
22
+ }
23
+
24
+ function makeDraftPlan(task: string, target: DraftTarget, source: DraftPlan["source"], cwd: string): DraftPlan {
25
+ const base = generateDraft(task, target, {
26
+ packageManager: "npm",
27
+ testCommand: "npm test",
28
+ lintCommand: "npm run lint",
29
+ hasGit: true,
30
+ topLevelDirs: ["src", "tests"],
31
+ topLevelFiles: ["package.json"],
32
+ });
33
+
34
+ return {
35
+ ...base,
36
+ source,
37
+ target,
38
+ content: base.content,
39
+ };
40
+ }
41
+
42
+ function createHarness(options?: { createDraftPlan?: (...args: Array<any>) => Promise<DraftPlan> }) {
43
+ const handlers = new Map<string, (args: string, ctx: any) => Promise<string | undefined>>();
44
+ const eventHandlers = new Map<string, (...args: Array<any>) => Promise<any> | any>();
45
+ const pi = {
46
+ on: (eventName: string, handler: (...args: Array<any>) => Promise<any> | any) => {
47
+ eventHandlers.set(eventName, handler);
48
+ },
49
+ registerCommand: (name: string, spec: { handler: (args: string, ctx: any) => Promise<string | undefined> }) => {
50
+ handlers.set(name, spec.handler);
51
+ },
52
+ appendEntry: () => undefined,
53
+ sendUserMessage: () => undefined,
54
+ } as any;
55
+
56
+ registerRalphCommands(pi, options as any);
57
+
58
+ return {
59
+ handler(name: string) {
60
+ const handler = handlers.get(name);
61
+ assert.ok(handler, `missing handler for ${name}`);
62
+ return handler;
63
+ },
64
+ event(name: string) {
65
+ const handler = eventHandlers.get(name);
66
+ assert.ok(handler, `missing event handler for ${name}`);
67
+ return handler;
68
+ },
69
+ };
70
+ }
71
+
72
+ test("/ralph reverse engineer this app with an injected llm-strengthened draft still shows review before start", async (t) => {
73
+ const cwd = createTempDir();
74
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
75
+
76
+ const task = "reverse engineer this app";
77
+ const target = createTarget(cwd, task);
78
+ const draftCalls: Array<{ task: string; target: DraftTarget; cwd: string }> = [];
79
+ const draftPlan = makeDraftPlan(task, target, "llm-strengthened", cwd);
80
+ const harness = createHarness({
81
+ createDraftPlan: async (taskArg: string, targetArg: DraftTarget, cwdArg: string) => {
82
+ draftCalls.push({ task: taskArg, target: targetArg, cwd: cwdArg });
83
+ return draftPlan;
84
+ },
85
+ });
86
+
87
+ const notifications: Array<{ message: string; level: string }> = [];
88
+ let selectTitle = "";
89
+ let selectOptions: string[] = [];
90
+ let newSessionCalls = 0;
91
+ const handler = harness.handler("ralph");
92
+ const ctx = {
93
+ cwd,
94
+ hasUI: true,
95
+ ui: {
96
+ select: async (title: string, options: string[]) => {
97
+ selectTitle = title;
98
+ selectOptions = options;
99
+ assert.deepEqual(draftCalls, [{ task, target, cwd }]);
100
+ assert.equal(existsSync(target.ralphPath), false, "draft file should not exist before review acceptance");
101
+ return "Start";
102
+ },
103
+ input: async () => undefined,
104
+ editor: async () => undefined,
105
+ notify: (message: string, level: string) => notifications.push({ message, level }),
106
+ setStatus: () => undefined,
107
+ },
108
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
109
+ newSession: async () => {
110
+ newSessionCalls += 1;
111
+ assert.equal(existsSync(target.ralphPath), true, "draft file should be written before the loop starts");
112
+ return { cancelled: true };
113
+ },
114
+ waitForIdle: async () => {
115
+ throw new Error("loop should not continue after cancelled session start");
116
+ },
117
+ };
118
+
119
+ await handler(task, ctx);
120
+
121
+ assert.equal(draftCalls.length, 1);
122
+ assert.equal(newSessionCalls, 1);
123
+ assert.equal(existsSync(target.ralphPath), true);
124
+ assert.match(selectTitle, /Mission Brief/);
125
+ assert.deepEqual(selectOptions, ["Start", "Open RALPH.md", "Cancel"]);
126
+ assert.equal(notifications.some(({ message }) => message.includes("Invalid RALPH.md")), false);
127
+ });
128
+
129
+ test("/ralph-draft with an injected fallback draft reviews and writes without surfacing model failure details", async (t) => {
130
+ const cwd = createTempDir();
131
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
132
+
133
+ const task = "reverse engineer this app";
134
+ const target = createTarget(cwd, task);
135
+ const draftCalls: Array<{ task: string; target: DraftTarget; cwd: string }> = [];
136
+ const draftPlan = makeDraftPlan(task, target, "fallback", cwd);
137
+ const harness = createHarness({
138
+ createDraftPlan: async (taskArg: string, targetArg: DraftTarget, cwdArg: string) => {
139
+ draftCalls.push({ task: taskArg, target: targetArg, cwd: cwdArg });
140
+ return draftPlan;
141
+ },
142
+ });
143
+
144
+ let selectTitle = "";
145
+ let selectOptions: string[] = [];
146
+ const handler = harness.handler("ralph-draft");
147
+ const ctx = {
148
+ cwd,
149
+ hasUI: true,
150
+ ui: {
151
+ select: async (title: string, options: string[]) => {
152
+ selectTitle = title;
153
+ selectOptions = options;
154
+ assert.deepEqual(draftCalls, [{ task, target, cwd }]);
155
+ assert.equal(existsSync(target.ralphPath), false, "draft file should not exist before Save draft");
156
+ return "Save draft";
157
+ },
158
+ input: async () => undefined,
159
+ editor: async () => undefined,
160
+ notify: () => undefined,
161
+ setStatus: () => undefined,
162
+ },
163
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
164
+ newSession: async () => {
165
+ throw new Error("/ralph-draft should not start the loop");
166
+ },
167
+ waitForIdle: async () => {
168
+ throw new Error("/ralph-draft should not wait for idle");
169
+ },
170
+ };
171
+
172
+ await handler(task, ctx);
173
+
174
+ assert.equal(draftCalls.length, 1);
175
+ assert.equal(existsSync(target.ralphPath), true);
176
+ assert.match(selectTitle, /Mission Brief/);
177
+ assert.match(selectTitle, /Task\s+reverse engineer this app/);
178
+ assert.doesNotMatch(selectTitle, /fallback|source|provenance|model failure/i);
179
+ assert.deepEqual(selectOptions, ["Save draft", "Open RALPH.md", "Cancel"]);
180
+ });
181
+
182
+ test("Mission Brief surface stays limited to the visible fields", async (t) => {
183
+ const cwd = createTempDir();
184
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
185
+
186
+ const task = "reverse engineer this app";
187
+ const target = createTarget(cwd, task);
188
+ const draftPlan = makeDraftPlan(task, target, "llm-strengthened", cwd);
189
+ const harness = createHarness({
190
+ createDraftPlan: async () => draftPlan,
191
+ });
192
+
193
+ let brief = "";
194
+ const handler = harness.handler("ralph-draft");
195
+ const ctx = {
196
+ cwd,
197
+ hasUI: true,
198
+ ui: {
199
+ select: async (title: string) => {
200
+ brief = title;
201
+ return "Cancel";
202
+ },
203
+ input: async () => undefined,
204
+ editor: async () => undefined,
205
+ notify: () => undefined,
206
+ setStatus: () => undefined,
207
+ },
208
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
209
+ newSession: async () => ({ cancelled: true }),
210
+ waitForIdle: async () => undefined,
211
+ };
212
+
213
+ await handler(task, ctx);
214
+
215
+ assert.match(brief, /^Mission Brief/m);
216
+ assert.match(brief, /^Task$/m);
217
+ assert.match(brief, /^File$/m);
218
+ assert.match(brief, /^Suggested checks$/m);
219
+ assert.match(brief, /^Finish behavior$/m);
220
+ assert.match(brief, /^Safety$/m);
221
+ assert.doesNotMatch(brief, /source|fallback|provenance|model failure/i);
222
+ assert.doesNotMatch(brief, /Draft status/);
223
+ });
224
+
225
+ test("natural-language drafting without UI warns and exits without creating a draft", async (t) => {
226
+ const cwd = createTempDir();
227
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
228
+
229
+ const task = "reverse engineer this app";
230
+ const target = createTarget(cwd, task);
231
+ const draftCalls: Array<{ task: string; target: DraftTarget; cwd: string }> = [];
232
+ const harness = createHarness({
233
+ createDraftPlan: async (taskArg: string, targetArg: DraftTarget, cwdArg: string) => {
234
+ draftCalls.push({ task: taskArg, target: targetArg, cwd: cwdArg });
235
+ return makeDraftPlan(task, target, "llm-strengthened", cwd);
236
+ },
237
+ });
238
+
239
+ const notifications: Array<{ message: string; level: string }> = [];
240
+ const handler = harness.handler("ralph");
241
+ const ctx = {
242
+ cwd,
243
+ hasUI: false,
244
+ ui: {
245
+ notify: (message: string, level: string) => notifications.push({ message, level }),
246
+ select: async () => {
247
+ throw new Error("should not open review UI");
248
+ },
249
+ input: async () => undefined,
250
+ editor: async () => undefined,
251
+ setStatus: () => undefined,
252
+ },
253
+ sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
254
+ newSession: async () => ({ cancelled: true }),
255
+ waitForIdle: async () => undefined,
256
+ };
257
+
258
+ await handler(task, ctx);
259
+
260
+ assert.equal(draftCalls.length, 0);
261
+ assert.equal(existsSync(target.ralphPath), false);
262
+ assert.deepEqual(notifications, [
263
+ {
264
+ level: "warning",
265
+ message: "Draft review requires an interactive session. Use /ralph with a task folder or RALPH.md path instead.",
266
+ },
267
+ ]);
268
+ });
269
+
270
+ test("/ralph --path existing-task/RALPH.md bypasses the drafting pipeline", async (t) => {
271
+ const cwd = createTempDir();
272
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
273
+
274
+ const task = "reverse engineer this app";
275
+ const target = createTarget(cwd, task);
276
+ const draftCalls: Array<{ task: string; target: DraftTarget; cwd: string }> = [];
277
+ const draftPlan = makeDraftPlan(task, target, "llm-strengthened", cwd);
278
+ const harness = createHarness({
279
+ createDraftPlan: async (taskArg: string, targetArg: DraftTarget, cwdArg: string) => {
280
+ draftCalls.push({ task: taskArg, target: targetArg, cwd: cwdArg });
281
+ return draftPlan;
282
+ },
283
+ });
284
+
285
+ const existingDir = join(cwd, "existing-task");
286
+ const existingRalphPath = join(existingDir, "RALPH.md");
287
+ await t.test("setup", () => undefined);
288
+ await import("node:fs").then(({ mkdirSync, writeFileSync }) => {
289
+ mkdirSync(existingDir, { recursive: true });
290
+ writeFileSync(existingRalphPath, draftPlan.content, "utf8");
291
+ });
292
+
293
+ const handler = harness.handler("ralph");
294
+ const ctx = {
295
+ cwd,
296
+ hasUI: false,
297
+ ui: {
298
+ notify: () => undefined,
299
+ select: async () => {
300
+ throw new Error("should not show review UI for existing RALPH.md");
301
+ },
302
+ input: async () => undefined,
303
+ editor: async () => undefined,
304
+ setStatus: () => undefined,
305
+ },
306
+ sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
307
+ newSession: async () => ({ cancelled: true }),
308
+ waitForIdle: async () => undefined,
309
+ };
310
+
311
+ await handler(`--path ${existingRalphPath}`, ctx);
312
+
313
+ assert.equal(draftCalls.length, 0);
314
+ });
315
+
316
+ test("/ralph-draft passes the active model runtime to the draft planner", async (t) => {
317
+ const cwd = createTempDir();
318
+ t.after(() => rmSync(cwd, { recursive: true, force: true }));
319
+
320
+ const task = "reverse engineer this app";
321
+ const target = createTarget(cwd, task);
322
+ const draftCalls: Array<{ task: string; target: DraftTarget; cwd: string; runtime: StrengthenDraftRuntime | undefined }> = [];
323
+ const draftPlan = makeDraftPlan(task, target, "llm-strengthened", cwd);
324
+ const runtime = {
325
+ model: {
326
+ provider: "anthropic",
327
+ id: "claude-sonnet-4-5",
328
+ name: "Claude Sonnet 4.5",
329
+ api: "anthropic-messages",
330
+ baseUrl: "https://example.invalid",
331
+ reasoning: false,
332
+ input: ["text"],
333
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
334
+ contextWindow: 200_000,
335
+ maxTokens: 8_192,
336
+ },
337
+ modelRegistry: {
338
+ async getApiKeyAndHeaders(model) {
339
+ assert.equal(model.id, "claude-sonnet-4-5");
340
+ return { ok: true, apiKey: "active-api-key", headers: { "x-runtime": "1" } };
341
+ },
342
+ },
343
+ } satisfies StrengthenDraftRuntime;
344
+ const harness = createHarness({
345
+ createDraftPlan: async (taskArg: string, targetArg: DraftTarget, cwdArg: string, runtimeArg: StrengthenDraftRuntime | undefined) => {
346
+ draftCalls.push({ task: taskArg, target: targetArg, cwd: cwdArg, runtime: runtimeArg });
347
+ assert.ok(runtimeArg, "expected the active model runtime to reach the draft planner");
348
+ assert.equal(runtimeArg?.model?.id, runtime.model.id);
349
+ assert.equal(runtimeArg?.modelRegistry, runtime.modelRegistry);
350
+ return draftPlan;
351
+ },
352
+ });
353
+
354
+ const handler = harness.handler("ralph-draft");
355
+ const ctx = {
356
+ cwd,
357
+ hasUI: true,
358
+ ui: {
359
+ select: async () => "Save draft",
360
+ input: async () => undefined,
361
+ editor: async () => undefined,
362
+ notify: () => undefined,
363
+ setStatus: () => undefined,
364
+ },
365
+ model: runtime.model,
366
+ modelRegistry: runtime.modelRegistry,
367
+ sessionManager: { getEntries: () => [], getSessionFile: () => "session-a" },
368
+ newSession: async () => {
369
+ throw new Error("/ralph-draft should not start the loop");
370
+ },
371
+ waitForIdle: async () => {
372
+ throw new Error("/ralph-draft should not wait for idle");
373
+ },
374
+ };
375
+
376
+ await handler(task, ctx);
377
+
378
+ assert.equal(draftCalls.length, 1);
379
+ assert.equal(existsSync(target.ralphPath), true);
380
+ });
381
+
382
+ test("tool_call blocks write and edit for token-covered secret paths", async () => {
383
+ const harness = createHarness();
384
+ const toolCall = harness.event("tool_call");
385
+ const ctx = {
386
+ sessionManager: {
387
+ getEntries: () => [
388
+ {
389
+ type: "custom",
390
+ customType: "ralph-loop-state",
391
+ data: {
392
+ active: true,
393
+ sessionFile: "session-a",
394
+ guardrails: { blockCommands: [], protectedFiles: [SECRET_PATH_POLICY_TOKEN] },
395
+ },
396
+ },
397
+ ],
398
+ getSessionFile: () => "session-a",
399
+ },
400
+ };
401
+
402
+ for (const toolName of ["write", "edit"] as const) {
403
+ const result = await toolCall({ toolName, input: { path: ".ssh/config" } }, ctx);
404
+ assert.deepEqual(result, { block: true, reason: "ralph: .ssh/config is protected" });
405
+ }
406
+ });
407
+
408
+ test("tool_call blocks absolute write paths against repo-relative protected globs", async () => {
409
+ const harness = createHarness();
410
+ const toolCall = harness.event("tool_call");
411
+ const cwd = "/repo/project";
412
+ const absolutePath = join(cwd, "src", "generated", "output.ts");
413
+ const ctx = {
414
+ sessionManager: {
415
+ getEntries: () => [
416
+ {
417
+ type: "custom",
418
+ customType: "ralph-loop-state",
419
+ data: {
420
+ active: true,
421
+ sessionFile: "session-a",
422
+ cwd,
423
+ guardrails: { blockCommands: [], protectedFiles: ["src/generated/**"] },
424
+ },
425
+ },
426
+ ],
427
+ getSessionFile: () => "session-a",
428
+ },
429
+ };
430
+
431
+ for (const toolName of ["write", "edit"] as const) {
432
+ const result = await toolCall({ toolName, input: { path: absolutePath } }, ctx);
433
+ assert.deepEqual(result, { block: true, reason: `ralph: ${absolutePath} is protected` });
434
+ }
435
+ });
436
+
437
+ test("tool_call keeps explicit protected-file globs working", async () => {
438
+ const harness = createHarness();
439
+ const toolCall = harness.event("tool_call");
440
+ const ctx = {
441
+ sessionManager: {
442
+ getEntries: () => [
443
+ {
444
+ type: "custom",
445
+ customType: "ralph-loop-state",
446
+ data: {
447
+ active: true,
448
+ sessionFile: "session-a",
449
+ guardrails: { blockCommands: [], protectedFiles: ["src/generated/**"] },
450
+ },
451
+ },
452
+ ],
453
+ getSessionFile: () => "session-a",
454
+ },
455
+ };
456
+
457
+ for (const toolName of ["write", "edit"] as const) {
458
+ const result = await toolCall({ toolName, input: { path: "src/generated/output.ts" } }, ctx);
459
+ assert.deepEqual(result, { block: true, reason: "ralph: src/generated/output.ts is protected" });
460
+ }
461
+
462
+ const allowed = await toolCall({ toolName: "write", input: { path: "src/app.ts" } }, ctx);
463
+ assert.equal(allowed, undefined);
464
+ });