@lnilluv/pi-ralph-loop 0.1.3 → 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.
- package/.github/workflows/ci.yml +5 -2
- package/.github/workflows/release.yml +7 -4
- package/README.md +97 -12
- package/package.json +13 -4
- package/src/index.ts +561 -184
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +269 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +800 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +23 -0
- package/tests/index.test.ts +464 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +361 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +611 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tsconfig.json +3 -2
|
@@ -0,0 +1,611 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
buildDraftRequest,
|
|
8
|
+
buildMissionBrief,
|
|
9
|
+
buildRepoContext,
|
|
10
|
+
classifyTaskMode,
|
|
11
|
+
createSiblingTarget,
|
|
12
|
+
defaultFrontmatter,
|
|
13
|
+
extractDraftMetadata,
|
|
14
|
+
generateDraft,
|
|
15
|
+
isWeakStrengthenedDraft,
|
|
16
|
+
normalizeStrengthenedDraft,
|
|
17
|
+
inspectExistingTarget,
|
|
18
|
+
inspectRepo,
|
|
19
|
+
looksLikePath,
|
|
20
|
+
nextSiblingSlug,
|
|
21
|
+
parseCommandArgs,
|
|
22
|
+
parseRalphMarkdown,
|
|
23
|
+
planTaskDraftTarget,
|
|
24
|
+
renderIterationPrompt,
|
|
25
|
+
renderRalphBody,
|
|
26
|
+
resolvePlaceholders,
|
|
27
|
+
slugifyTask,
|
|
28
|
+
shouldValidateExistingDraft,
|
|
29
|
+
validateDraftContent,
|
|
30
|
+
validateFrontmatter,
|
|
31
|
+
} from "../src/ralph.ts";
|
|
32
|
+
import { SECRET_PATH_POLICY_TOKEN } from "../src/secret-paths.ts";
|
|
33
|
+
import type { RepoSignals } from "../src/ralph.ts";
|
|
34
|
+
import registerRalphCommands, { runCommands } from "../src/index.ts";
|
|
35
|
+
|
|
36
|
+
function createTempDir(): string {
|
|
37
|
+
return mkdtempSync(join(tmpdir(), "pi-ralph-loop-"));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function encodeMetadata(metadata: Record<string, unknown>): string {
|
|
41
|
+
return `<!-- pi-ralph-loop: ${encodeURIComponent(JSON.stringify(metadata))} -->`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createCommandHarness() {
|
|
45
|
+
const handlers = new Map<string, (args: string, ctx: any) => Promise<string | undefined>>();
|
|
46
|
+
const pi = {
|
|
47
|
+
on: () => undefined,
|
|
48
|
+
registerCommand: (name: string, spec: { handler: (args: string, ctx: any) => Promise<string | undefined> }) => {
|
|
49
|
+
handlers.set(name, spec.handler);
|
|
50
|
+
},
|
|
51
|
+
appendEntry: () => undefined,
|
|
52
|
+
sendUserMessage: () => undefined,
|
|
53
|
+
} as any;
|
|
54
|
+
|
|
55
|
+
registerRalphCommands(pi);
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
handler(name: string) {
|
|
59
|
+
const handler = handlers.get(name);
|
|
60
|
+
assert.ok(handler);
|
|
61
|
+
return handler;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function assertMetadataSource(metadata: ReturnType<typeof extractDraftMetadata>, expected: "deterministic" | "llm-strengthened" | "fallback") {
|
|
67
|
+
if (!metadata || !("source" in metadata)) {
|
|
68
|
+
assert.fail("Expected draft metadata with a source");
|
|
69
|
+
}
|
|
70
|
+
assert.equal(metadata.source, expected);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
test("parseRalphMarkdown falls back to default frontmatter when no frontmatter is present", () => {
|
|
74
|
+
const parsed = parseRalphMarkdown("hello\nworld");
|
|
75
|
+
|
|
76
|
+
assert.deepEqual(parsed.frontmatter, defaultFrontmatter());
|
|
77
|
+
assert.equal(parsed.body, "hello\nworld");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("parseRalphMarkdown parses frontmatter and normalizes line endings", () => {
|
|
81
|
+
const parsed = parseRalphMarkdown(
|
|
82
|
+
"\uFEFF---\r\ncommands:\r\n - name: build\r\n run: npm test\r\n timeout: 15\r\nmax_iterations: 3\r\ntimeout: 12.5\r\ncompletion_promise: done\r\nguardrails:\r\n block_commands:\r\n - rm .*\r\n protected_files:\r\n - src/**\r\n---\r\nBody\r\n",
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
assert.deepEqual(parsed.frontmatter, {
|
|
86
|
+
commands: [{ name: "build", run: "npm test", timeout: 15 }],
|
|
87
|
+
maxIterations: 3,
|
|
88
|
+
timeout: 12.5,
|
|
89
|
+
completionPromise: "done",
|
|
90
|
+
guardrails: { blockCommands: ["rm .*"], protectedFiles: ["src/**"] },
|
|
91
|
+
invalidCommandEntries: undefined,
|
|
92
|
+
});
|
|
93
|
+
assert.equal(parsed.body, "Body\n");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("validateFrontmatter accepts valid input and rejects invalid values", () => {
|
|
97
|
+
assert.equal(validateFrontmatter(defaultFrontmatter()), null);
|
|
98
|
+
assert.equal(
|
|
99
|
+
validateFrontmatter({ ...defaultFrontmatter(), maxIterations: 0 }),
|
|
100
|
+
"Invalid max_iterations: must be a positive finite integer",
|
|
101
|
+
);
|
|
102
|
+
assert.equal(
|
|
103
|
+
validateFrontmatter({ ...defaultFrontmatter(), timeout: 0 }),
|
|
104
|
+
"Invalid timeout: must be a positive finite number",
|
|
105
|
+
);
|
|
106
|
+
assert.equal(
|
|
107
|
+
validateFrontmatter({ ...defaultFrontmatter(), guardrails: { blockCommands: ["["], protectedFiles: [] } }),
|
|
108
|
+
"Invalid block_commands regex: [",
|
|
109
|
+
);
|
|
110
|
+
assert.equal(
|
|
111
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "", run: "echo ok", timeout: 1 }] }),
|
|
112
|
+
"Invalid command: name is required",
|
|
113
|
+
);
|
|
114
|
+
assert.equal(
|
|
115
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "", timeout: 1 }] }),
|
|
116
|
+
"Invalid command build: run is required",
|
|
117
|
+
);
|
|
118
|
+
assert.equal(
|
|
119
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "echo ok", timeout: 0 }] }),
|
|
120
|
+
"Invalid command build: timeout must be positive",
|
|
121
|
+
);
|
|
122
|
+
assert.equal(
|
|
123
|
+
validateFrontmatter(parseRalphMarkdown("---\ncommands:\n - nope\n - null\n---\nbody").frontmatter),
|
|
124
|
+
"Invalid command entry at index 0",
|
|
125
|
+
);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("runCommands skips blocked commands before shelling out", async () => {
|
|
129
|
+
const calls: string[] = [];
|
|
130
|
+
const pi = {
|
|
131
|
+
exec: async (_tool: string, args: string[]) => {
|
|
132
|
+
calls.push(args.join(" "));
|
|
133
|
+
return { killed: false, stdout: "allowed", stderr: "" };
|
|
134
|
+
},
|
|
135
|
+
} as any;
|
|
136
|
+
|
|
137
|
+
const outputs = await runCommands(
|
|
138
|
+
[
|
|
139
|
+
{ name: "blocked", run: "git push origin main", timeout: 1 },
|
|
140
|
+
{ name: "allowed", run: "echo ok", timeout: 1 },
|
|
141
|
+
],
|
|
142
|
+
["git\\s+push"],
|
|
143
|
+
pi,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
assert.deepEqual(outputs, [
|
|
147
|
+
{ name: "blocked", output: "[blocked by guardrail: git\\s+push]" },
|
|
148
|
+
{ name: "allowed", output: "allowed" },
|
|
149
|
+
]);
|
|
150
|
+
assert.deepEqual(calls, ["-c echo ok"]);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("legacy RALPH.md drafts bypass the generated-draft validation gate", () => {
|
|
154
|
+
assert.equal(shouldValidateExistingDraft("Task body"), false);
|
|
155
|
+
|
|
156
|
+
const draft = generateDraft(
|
|
157
|
+
"Fix flaky auth tests",
|
|
158
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
159
|
+
{ packageManager: "npm", hasGit: false, topLevelDirs: [], topLevelFiles: [] },
|
|
160
|
+
);
|
|
161
|
+
assert.equal(shouldValidateExistingDraft(draft.content), true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("render helpers expand placeholders and strip comments", () => {
|
|
165
|
+
const outputs = [{ name: "build", output: "done" }];
|
|
166
|
+
|
|
167
|
+
assert.equal(
|
|
168
|
+
resolvePlaceholders("{{ commands.build }} {{ ralph.iteration }} {{ ralph.name }} {{ commands.missing }}", outputs, {
|
|
169
|
+
iteration: 7,
|
|
170
|
+
name: "ralph",
|
|
171
|
+
}),
|
|
172
|
+
"done 7 ralph ",
|
|
173
|
+
);
|
|
174
|
+
assert.equal(renderRalphBody("keep<!-- hidden -->{{ ralph.name }}", [], { iteration: 1, name: "ralph" }), "keepralph");
|
|
175
|
+
assert.equal(renderIterationPrompt("Body", 2, 5), "[ralph: iteration 2/5]\n\nBody");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("parseCommandArgs handles explicit task/path flags and auto mode", () => {
|
|
179
|
+
assert.deepEqual(parseCommandArgs("--task reverse engineer auth"), { mode: "task", value: "reverse engineer auth" });
|
|
180
|
+
assert.deepEqual(parseCommandArgs("--path my-task"), { mode: "path", value: "my-task" });
|
|
181
|
+
assert.deepEqual(parseCommandArgs("--task=fix flaky tests"), { mode: "task", value: "fix flaky tests" });
|
|
182
|
+
assert.deepEqual(parseCommandArgs(" reverse engineer this app "), { mode: "auto", value: "reverse engineer this app" });
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("explicit path mode stays path-centric and does not offer task fallback", async () => {
|
|
186
|
+
const harness = createCommandHarness();
|
|
187
|
+
const handler = harness.handler("ralph");
|
|
188
|
+
const selectOptions: string[][] = [];
|
|
189
|
+
const ctx = {
|
|
190
|
+
cwd: createTempDir(),
|
|
191
|
+
hasUI: true,
|
|
192
|
+
ui: {
|
|
193
|
+
select: async (_title: string, options: string[]) => {
|
|
194
|
+
selectOptions.push(options);
|
|
195
|
+
return "Cancel";
|
|
196
|
+
},
|
|
197
|
+
input: async () => {
|
|
198
|
+
throw new Error("should not prompt for task text");
|
|
199
|
+
},
|
|
200
|
+
notify: () => undefined,
|
|
201
|
+
editor: async () => undefined,
|
|
202
|
+
setStatus: () => undefined,
|
|
203
|
+
},
|
|
204
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
|
|
205
|
+
newSession: async () => ({ cancelled: true }),
|
|
206
|
+
waitForIdle: async () => undefined,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
await handler("--path reverse engineer auth", ctx);
|
|
210
|
+
|
|
211
|
+
assert.deepEqual(selectOptions, [["Draft in that folder", "Cancel"]]);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("path detection and existing-target inspection distinguish runnable Ralph targets from arbitrary markdown", (t) => {
|
|
215
|
+
const cwd = createTempDir();
|
|
216
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
217
|
+
|
|
218
|
+
mkdirSync(join(cwd, "task"), { recursive: true });
|
|
219
|
+
mkdirSync(join(cwd, "empty"), { recursive: true });
|
|
220
|
+
writeFileSync(join(cwd, "task", "RALPH.md"), "Task body", "utf8");
|
|
221
|
+
writeFileSync(join(cwd, "README.md"), "not runnable", "utf8");
|
|
222
|
+
writeFileSync(join(cwd, "package.json"), "{}", "utf8");
|
|
223
|
+
|
|
224
|
+
assert.equal(looksLikePath("reverse engineer auth"), false);
|
|
225
|
+
assert.equal(looksLikePath("auth-audit"), true);
|
|
226
|
+
assert.equal(looksLikePath("README.md"), true);
|
|
227
|
+
assert.equal(looksLikePath("foo/bar"), true);
|
|
228
|
+
assert.equal(looksLikePath("~draft"), false);
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(inspectExistingTarget("task", cwd), { kind: "run", ralphPath: join(cwd, "task", "RALPH.md") });
|
|
231
|
+
assert.deepEqual(inspectExistingTarget("reverse engineer auth", cwd, true), {
|
|
232
|
+
kind: "missing-path",
|
|
233
|
+
dirPath: join(cwd, "reverse engineer auth"),
|
|
234
|
+
ralphPath: join(cwd, "reverse engineer auth", "RALPH.md"),
|
|
235
|
+
});
|
|
236
|
+
assert.deepEqual(inspectExistingTarget("README.md", cwd), { kind: "invalid-markdown", path: join(cwd, "README.md") });
|
|
237
|
+
assert.deepEqual(inspectExistingTarget("package.json", cwd), { kind: "invalid-target", path: join(cwd, "package.json") });
|
|
238
|
+
assert.deepEqual(inspectExistingTarget("empty", cwd), {
|
|
239
|
+
kind: "dir-without-ralph",
|
|
240
|
+
dirPath: join(cwd, "empty"),
|
|
241
|
+
ralphPath: join(cwd, "empty", "RALPH.md"),
|
|
242
|
+
});
|
|
243
|
+
assert.deepEqual(inspectExistingTarget("missing-path", cwd), {
|
|
244
|
+
kind: "missing-path",
|
|
245
|
+
dirPath: join(cwd, "missing-path"),
|
|
246
|
+
ralphPath: join(cwd, "missing-path", "RALPH.md"),
|
|
247
|
+
});
|
|
248
|
+
assert.deepEqual(inspectExistingTarget("foo/bar", cwd), {
|
|
249
|
+
kind: "missing-path",
|
|
250
|
+
dirPath: join(cwd, "foo/bar"),
|
|
251
|
+
ralphPath: join(cwd, "foo/bar", "RALPH.md"),
|
|
252
|
+
});
|
|
253
|
+
assert.deepEqual(inspectExistingTarget("notes.md", cwd), {
|
|
254
|
+
kind: "missing-path",
|
|
255
|
+
dirPath: join(cwd, "notes"),
|
|
256
|
+
ralphPath: join(cwd, "notes", "RALPH.md"),
|
|
257
|
+
});
|
|
258
|
+
assert.deepEqual(inspectExistingTarget("reverse engineer auth", cwd), { kind: "not-path" });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("validateDraftContent rejects missing and malformed frontmatter", () => {
|
|
262
|
+
assert.equal(validateDraftContent("Task body"), "Missing RALPH frontmatter");
|
|
263
|
+
assert.equal(
|
|
264
|
+
validateDraftContent("---\nmax_iterations: 0\n---\nBody"),
|
|
265
|
+
"Invalid max_iterations: must be a positive finite integer",
|
|
266
|
+
);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("buildMissionBrief fails closed when the current draft content is invalid", () => {
|
|
270
|
+
const plan = generateDraft(
|
|
271
|
+
"Fix flaky auth tests",
|
|
272
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
273
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const brief = buildMissionBrief({ ...plan, content: "Task: Fix flaky auth tests\n\nThis draft no longer has frontmatter." });
|
|
277
|
+
|
|
278
|
+
assert.match(brief, /Invalid RALPH\.md: Missing RALPH frontmatter/);
|
|
279
|
+
assert.match(brief, /Task metadata missing from current draft|Fix flaky auth tests/);
|
|
280
|
+
assert.doesNotMatch(brief, /Suggested checks/);
|
|
281
|
+
assert.doesNotMatch(brief, /Finish behavior/);
|
|
282
|
+
assert.doesNotMatch(brief, /Safety/);
|
|
283
|
+
assert.doesNotMatch(brief, /tests: npm test/);
|
|
284
|
+
assert.doesNotMatch(brief, /Stop after 25 iterations or \/ralph-stop/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("slug helpers skip occupied directories when planning siblings", (t) => {
|
|
288
|
+
const cwd = createTempDir();
|
|
289
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
290
|
+
|
|
291
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app"), { recursive: true });
|
|
292
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app-2"), { recursive: true });
|
|
293
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app-3"), { recursive: true });
|
|
294
|
+
|
|
295
|
+
assert.equal(slugifyTask("Reverse engineer this app!"), "reverse-engineer-this-app");
|
|
296
|
+
assert.equal(slugifyTask("!!!"), "ralph-task");
|
|
297
|
+
assert.equal(
|
|
298
|
+
nextSiblingSlug(
|
|
299
|
+
"reverse-engineer-this-app",
|
|
300
|
+
(slug) => slug === "reverse-engineer-this-app-2" || slug === "reverse-engineer-this-app-3",
|
|
301
|
+
),
|
|
302
|
+
"reverse-engineer-this-app-4",
|
|
303
|
+
);
|
|
304
|
+
assert.deepEqual(planTaskDraftTarget(cwd, "Reverse engineer this app"), {
|
|
305
|
+
kind: "conflict",
|
|
306
|
+
target: {
|
|
307
|
+
slug: "reverse-engineer-this-app",
|
|
308
|
+
dirPath: join(cwd, "reverse-engineer-this-app"),
|
|
309
|
+
ralphPath: join(cwd, "reverse-engineer-this-app", "RALPH.md"),
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
assert.equal(createSiblingTarget(cwd, "reverse-engineer-this-app").slug, "reverse-engineer-this-app-4");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("task classification identifies analysis, fix, migration, and general modes", () => {
|
|
316
|
+
assert.equal(classifyTaskMode("Reverse engineer the billing flow"), "analysis");
|
|
317
|
+
assert.equal(classifyTaskMode("Fix flaky auth tests"), "fix");
|
|
318
|
+
assert.equal(classifyTaskMode("Migrate this package to ESM"), "migration");
|
|
319
|
+
assert.equal(classifyTaskMode("Improve the login page"), "general");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("inspectRepo detects bounded package signals", (t) => {
|
|
323
|
+
const cwd = createTempDir();
|
|
324
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
325
|
+
|
|
326
|
+
mkdirSync(join(cwd, ".git"));
|
|
327
|
+
mkdirSync(join(cwd, "src"));
|
|
328
|
+
writeFileSync(join(cwd, "package-lock.json"), "{}", "utf8");
|
|
329
|
+
writeFileSync(
|
|
330
|
+
join(cwd, "package.json"),
|
|
331
|
+
JSON.stringify({ name: "demo", scripts: { test: "vitest", lint: "eslint ." } }, null, 2),
|
|
332
|
+
"utf8",
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
assert.deepEqual(inspectRepo(cwd), {
|
|
336
|
+
packageManager: "npm",
|
|
337
|
+
testCommand: "npm test",
|
|
338
|
+
lintCommand: "npm run lint",
|
|
339
|
+
hasGit: true,
|
|
340
|
+
topLevelDirs: [".git", "src"],
|
|
341
|
+
topLevelFiles: ["package-lock.json", "package.json"],
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
test("generated drafts reparse as valid RALPH files", () => {
|
|
346
|
+
const draft = generateDraft(
|
|
347
|
+
"Reverse engineer this app",
|
|
348
|
+
{ slug: "reverse-engineer-this-app", dirPath: "/repo/reverse-engineer-this-app", ralphPath: "/repo/reverse-engineer-this-app/RALPH.md" },
|
|
349
|
+
{ packageManager: "npm", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
const reparsed = parseRalphMarkdown(draft.content);
|
|
353
|
+
assert.equal(validateFrontmatter(reparsed.frontmatter), null);
|
|
354
|
+
assert.equal(draft.source, "deterministic");
|
|
355
|
+
assertMetadataSource(extractDraftMetadata(draft.content), "deterministic");
|
|
356
|
+
assert.deepEqual(reparsed.frontmatter.commands, [
|
|
357
|
+
{ name: "git-log", run: "git log --oneline -10", timeout: 20 },
|
|
358
|
+
{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
|
|
359
|
+
]);
|
|
360
|
+
assert.deepEqual(reparsed.frontmatter, {
|
|
361
|
+
commands: [
|
|
362
|
+
{ name: "git-log", run: "git log --oneline -10", timeout: 20 },
|
|
363
|
+
{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
|
|
364
|
+
],
|
|
365
|
+
maxIterations: 12,
|
|
366
|
+
timeout: 300,
|
|
367
|
+
completionPromise: undefined,
|
|
368
|
+
guardrails: { blockCommands: ["git\\s+push"], protectedFiles: [] },
|
|
369
|
+
invalidCommandEntries: undefined,
|
|
370
|
+
});
|
|
371
|
+
assert.match(reparsed.body, /Task: Reverse engineer this app/);
|
|
372
|
+
assert.match(reparsed.body, /\{\{ commands.git-log \}\}/);
|
|
373
|
+
assert.match(reparsed.body, /\{\{ ralph.iteration \}\}/);
|
|
374
|
+
assert.equal(extractDraftMetadata(draft.content)?.mode, "analysis");
|
|
375
|
+
assertMetadataSource(extractDraftMetadata(draft.content), "deterministic");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test("extractDraftMetadata accepts Phase 1 and Phase 2 metadata", () => {
|
|
379
|
+
const phase1 = `${encodeMetadata({ generator: "pi-ralph-loop", version: 1, task: "Fix flaky auth tests", mode: "fix" })}\n---\ncommands: []\nmax_iterations: 25\ntimeout: 300\nguardrails:\n block_commands: []\n protected_files: []\n---\nBody`;
|
|
380
|
+
const phase2 = `${encodeMetadata({ generator: "pi-ralph-loop", version: 2, source: "llm-strengthened", task: "Fix flaky auth tests", mode: "fix" })}\n---\ncommands: []\nmax_iterations: 25\ntimeout: 300\nguardrails:\n block_commands: []\n protected_files: []\n---\nBody`;
|
|
381
|
+
|
|
382
|
+
assert.deepEqual(extractDraftMetadata(phase1), {
|
|
383
|
+
generator: "pi-ralph-loop",
|
|
384
|
+
version: 1,
|
|
385
|
+
task: "Fix flaky auth tests",
|
|
386
|
+
mode: "fix",
|
|
387
|
+
});
|
|
388
|
+
assert.deepEqual(extractDraftMetadata(phase2), {
|
|
389
|
+
generator: "pi-ralph-loop",
|
|
390
|
+
version: 2,
|
|
391
|
+
source: "llm-strengthened",
|
|
392
|
+
task: "Fix flaky auth tests",
|
|
393
|
+
mode: "fix",
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test("buildDraftRequest tags deterministic command intents and seeds a baseline draft", () => {
|
|
398
|
+
const repoSignals: RepoSignals = { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] };
|
|
399
|
+
const repoContext = buildRepoContext(repoSignals);
|
|
400
|
+
const request = buildDraftRequest(
|
|
401
|
+
"Fix flaky auth tests",
|
|
402
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
403
|
+
repoSignals,
|
|
404
|
+
repoContext,
|
|
405
|
+
);
|
|
406
|
+
|
|
407
|
+
assert.equal(request.mode, "fix");
|
|
408
|
+
assert.deepEqual(request.repoSignals, repoSignals);
|
|
409
|
+
assert.deepEqual(request.repoContext, repoContext);
|
|
410
|
+
assert.deepEqual(request.repoContext.selectedFiles, [{ path: "package.json", content: "", reason: "top-level file" }]);
|
|
411
|
+
assert.deepEqual(
|
|
412
|
+
request.commandIntent.map(({ name, source }) => ({ name, source })),
|
|
413
|
+
[
|
|
414
|
+
{ name: "tests", source: "repo-signal" },
|
|
415
|
+
{ name: "lint", source: "repo-signal" },
|
|
416
|
+
{ name: "git-log", source: "heuristic" },
|
|
417
|
+
],
|
|
418
|
+
);
|
|
419
|
+
assertMetadataSource(extractDraftMetadata(request.baselineDraft), "deterministic");
|
|
420
|
+
assert.ok(request.baselineDraft.length > 0);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("normalizeStrengthenedDraft keeps deterministic frontmatter in body-only mode", () => {
|
|
424
|
+
const request = buildDraftRequest(
|
|
425
|
+
"Fix flaky auth tests",
|
|
426
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
427
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
428
|
+
{ summaryLines: ["repo summary"], selectedFiles: [{ path: "package.json", content: "", reason: "top-level file" }] },
|
|
429
|
+
);
|
|
430
|
+
const baseline = parseRalphMarkdown(request.baselineDraft);
|
|
431
|
+
const strengthenedDraft = `${encodeMetadata({ generator: "pi-ralph-loop", version: 2, source: "llm-strengthened", task: "Fix flaky auth tests", mode: "fix" })}\n---\ncommands:\n - name: rogue\n run: rm -rf /\n timeout: 1\nmax_iterations: 1\ntimeout: 1\nguardrails:\n block_commands:\n - allow-all\n protected_files:\n - tmp/**\n---\nTask: Fix flaky auth tests\n\nRead-only enforced and write protection is enforced.`;
|
|
432
|
+
|
|
433
|
+
const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
|
|
434
|
+
const reparsed = parseRalphMarkdown(normalized.content);
|
|
435
|
+
|
|
436
|
+
assert.deepEqual(reparsed.frontmatter.commands, baseline.frontmatter.commands);
|
|
437
|
+
assert.deepEqual(reparsed.frontmatter.guardrails, baseline.frontmatter.guardrails);
|
|
438
|
+
assert.equal(reparsed.body.trimStart(), "Task: Fix flaky auth tests\n\nRead-only enforced and write protection is enforced.");
|
|
439
|
+
assert.deepEqual(extractDraftMetadata(normalized.content), {
|
|
440
|
+
generator: "pi-ralph-loop",
|
|
441
|
+
version: 2,
|
|
442
|
+
source: "llm-strengthened",
|
|
443
|
+
task: "Fix flaky auth tests",
|
|
444
|
+
mode: "fix",
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("normalizeStrengthenedDraft applies strengthened commands in body-and-commands mode", () => {
|
|
449
|
+
const request = buildDraftRequest(
|
|
450
|
+
"Fix flaky auth tests",
|
|
451
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
452
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
453
|
+
{ summaryLines: ["repo summary"], selectedFiles: [{ path: "package.json", content: "", reason: "top-level file" }] },
|
|
454
|
+
);
|
|
455
|
+
const strengthenedDraft = `${encodeMetadata({ generator: "pi-ralph-loop", version: 2, source: "llm-strengthened", task: "Fix flaky auth tests", mode: "fix" })}\n---\ncommands:\n - name: smoke\n run: npm run smoke\n timeout: 45\nmax_iterations: 7\ntimeout: 120\nguardrails:\n block_commands:\n - git\\s+push\n protected_files:\n - .env*\n---\nTask: Fix flaky auth tests\n\nUse the smoke check and keep the output concise.`;
|
|
456
|
+
|
|
457
|
+
const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-and-commands");
|
|
458
|
+
const reparsed = parseRalphMarkdown(normalized.content);
|
|
459
|
+
|
|
460
|
+
assert.deepEqual(reparsed.frontmatter.commands, [{ name: "smoke", run: "npm run smoke", timeout: 45 }]);
|
|
461
|
+
assert.equal(reparsed.frontmatter.maxIterations, 7);
|
|
462
|
+
assert.deepEqual(reparsed.frontmatter.guardrails, { blockCommands: ["git\\s+push"], protectedFiles: [".env*"] });
|
|
463
|
+
assert.match(reparsed.body, /Use the smoke check and keep the output concise\./);
|
|
464
|
+
assert.deepEqual(extractDraftMetadata(normalized.content), {
|
|
465
|
+
generator: "pi-ralph-loop",
|
|
466
|
+
version: 2,
|
|
467
|
+
source: "llm-strengthened",
|
|
468
|
+
task: "Fix flaky auth tests",
|
|
469
|
+
mode: "fix",
|
|
470
|
+
});
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
test("isWeakStrengthenedDraft rejects unchanged bodies and fake runtime enforcement claims", () => {
|
|
474
|
+
const request = buildDraftRequest(
|
|
475
|
+
"Fix flaky auth tests",
|
|
476
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
477
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
478
|
+
{ summaryLines: ["repo summary"], selectedFiles: [{ path: "package.json", content: "", reason: "top-level file" }] },
|
|
479
|
+
);
|
|
480
|
+
const baselineBody = parseRalphMarkdown(request.baselineDraft).body;
|
|
481
|
+
const unchangedBody = baselineBody;
|
|
482
|
+
const changedBody = `${baselineBody}\n\nAdd concrete verification steps.`;
|
|
483
|
+
|
|
484
|
+
assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", unchangedBody), true);
|
|
485
|
+
assert.equal(isWeakStrengthenedDraft(baselineBody, "read-only enforced", changedBody), true);
|
|
486
|
+
assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", `write protection is enforced\n\n${changedBody}`), true);
|
|
487
|
+
assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", changedBody), false);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("generated draft starts fail closed when validation no longer passes", async () => {
|
|
491
|
+
const cwd = createTempDir();
|
|
492
|
+
const targetDir = join(cwd, "generated-draft");
|
|
493
|
+
const ralphPath = join(targetDir, "RALPH.md");
|
|
494
|
+
mkdirSync(targetDir, { recursive: true });
|
|
495
|
+
const draft = generateDraft(
|
|
496
|
+
"Fix flaky auth tests",
|
|
497
|
+
{ slug: "generated-draft", dirPath: targetDir, ralphPath },
|
|
498
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
499
|
+
);
|
|
500
|
+
writeFileSync(ralphPath, draft.content.replace("max_iterations: 25", "max_iterations: 0"), "utf8");
|
|
501
|
+
|
|
502
|
+
const notifications: Array<{ level: string; message: string }> = [];
|
|
503
|
+
const harness = createCommandHarness();
|
|
504
|
+
const handler = harness.handler("ralph");
|
|
505
|
+
const ctx = {
|
|
506
|
+
cwd,
|
|
507
|
+
hasUI: true,
|
|
508
|
+
ui: {
|
|
509
|
+
notify: (message: string, level: string) => notifications.push({ level, message }),
|
|
510
|
+
select: async () => {
|
|
511
|
+
throw new Error("should not prompt");
|
|
512
|
+
},
|
|
513
|
+
input: async () => {
|
|
514
|
+
throw new Error("should not prompt");
|
|
515
|
+
},
|
|
516
|
+
editor: async () => undefined,
|
|
517
|
+
setStatus: () => undefined,
|
|
518
|
+
},
|
|
519
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
|
|
520
|
+
newSession: async () => {
|
|
521
|
+
throw new Error("should not start");
|
|
522
|
+
},
|
|
523
|
+
waitForIdle: async () => undefined,
|
|
524
|
+
};
|
|
525
|
+
|
|
526
|
+
await handler(`--path ${ralphPath}`, ctx);
|
|
527
|
+
|
|
528
|
+
assert.deepEqual(notifications, [{ level: "error", message: "Invalid RALPH.md: Invalid max_iterations: must be a positive finite integer" }]);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("generateDraft creates metadata-rich analysis and fix drafts", () => {
|
|
532
|
+
const analysisDraft = generateDraft(
|
|
533
|
+
"Reverse engineer this app",
|
|
534
|
+
{ slug: "reverse-engineer-this-app", dirPath: "/repo/reverse-engineer-this-app", ralphPath: "/repo/reverse-engineer-this-app/RALPH.md" },
|
|
535
|
+
{ packageManager: "npm", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
536
|
+
);
|
|
537
|
+
const analysisParsed = parseRalphMarkdown(analysisDraft.content);
|
|
538
|
+
assert.equal(analysisDraft.mode, "analysis");
|
|
539
|
+
assert.equal(analysisDraft.source, "deterministic");
|
|
540
|
+
assert.equal(extractDraftMetadata(analysisDraft.content)?.mode, "analysis");
|
|
541
|
+
assertMetadataSource(extractDraftMetadata(analysisDraft.content), "deterministic");
|
|
542
|
+
assert.match(analysisDraft.content, /Start with read-only inspection/);
|
|
543
|
+
assert.match(analysisDraft.content, /\{\{ commands.repo-map \}\}/);
|
|
544
|
+
assert.equal(analysisDraft.safetyLabel, "blocks git push");
|
|
545
|
+
assert.deepEqual(analysisParsed.frontmatter.guardrails.protectedFiles, []);
|
|
546
|
+
assert.doesNotMatch(analysisDraft.content, /\*\*\/\*/);
|
|
547
|
+
const analysisBrief = buildMissionBrief(analysisDraft);
|
|
548
|
+
assert.match(analysisBrief, /- blocks git push/);
|
|
549
|
+
assert.doesNotMatch(analysisBrief, /read-only/);
|
|
550
|
+
|
|
551
|
+
const fixDraft = generateDraft(
|
|
552
|
+
"Fix flaky auth tests",
|
|
553
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
554
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
555
|
+
);
|
|
556
|
+
const fixParsed = parseRalphMarkdown(fixDraft.content);
|
|
557
|
+
assert.equal(fixDraft.mode, "fix");
|
|
558
|
+
assert.equal(fixDraft.source, "deterministic");
|
|
559
|
+
assert.match(fixDraft.content, /If tests or lint are failing/);
|
|
560
|
+
assert.match(fixDraft.content, /\{\{ commands.tests \}\}/);
|
|
561
|
+
assert.match(fixDraft.content, /\{\{ commands.lint \}\}/);
|
|
562
|
+
assert.equal(extractDraftMetadata(fixDraft.content)?.task, "Fix flaky auth tests");
|
|
563
|
+
assertMetadataSource(extractDraftMetadata(fixDraft.content), "deterministic");
|
|
564
|
+
assert.deepEqual(fixParsed.frontmatter.guardrails.protectedFiles, [SECRET_PATH_POLICY_TOKEN]);
|
|
565
|
+
assert.match(fixDraft.safetyLabel, /secret files/);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("generated draft metadata survives task text containing HTML comment markers", () => {
|
|
569
|
+
const task = "Reverse engineer the parser <!-- tricky --> and document the edge case";
|
|
570
|
+
const draft = generateDraft(
|
|
571
|
+
task,
|
|
572
|
+
{
|
|
573
|
+
slug: "reverse-engineer-the-parser-and-document-the-edge-case",
|
|
574
|
+
dirPath: "/repo/reverse-engineer-the-parser-and-document-the-edge-case",
|
|
575
|
+
ralphPath: "/repo/reverse-engineer-the-parser-and-document-the-edge-case/RALPH.md",
|
|
576
|
+
},
|
|
577
|
+
{ packageManager: "npm", hasGit: false, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
578
|
+
);
|
|
579
|
+
const parsed = parseRalphMarkdown(draft.content);
|
|
580
|
+
|
|
581
|
+
assert.equal(extractDraftMetadata(draft.content)?.task, task);
|
|
582
|
+
assert.equal(validateDraftContent(draft.content), null);
|
|
583
|
+
assert.match(draft.content, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
584
|
+
assert.match(parsed.body, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
585
|
+
const rendered = renderRalphBody(parsed.body, [], { iteration: 1, name: "ralph" });
|
|
586
|
+
assert.match(rendered, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
587
|
+
assert.doesNotMatch(rendered, /<!-- tricky -->/);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test("buildMissionBrief refreshes after draft edits", () => {
|
|
591
|
+
const plan = generateDraft(
|
|
592
|
+
"Fix flaky auth tests",
|
|
593
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
594
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: false, topLevelDirs: [], topLevelFiles: [] },
|
|
595
|
+
);
|
|
596
|
+
const editedPlan = {
|
|
597
|
+
...plan,
|
|
598
|
+
content: plan.content
|
|
599
|
+
.replace("Task: Fix flaky auth tests", "Task: Fix flaky auth regressions")
|
|
600
|
+
.replace("name: tests\n run: npm test\n timeout: 120", "name: smoke\n run: npm run smoke\n timeout: 45")
|
|
601
|
+
.replace("max_iterations: 25", "max_iterations: 7"),
|
|
602
|
+
};
|
|
603
|
+
|
|
604
|
+
const brief = buildMissionBrief(editedPlan);
|
|
605
|
+
assert.match(brief, /Mission Brief/);
|
|
606
|
+
assert.match(brief, /Fix flaky auth regressions/);
|
|
607
|
+
assert.doesNotMatch(brief, /Fix flaky auth tests/);
|
|
608
|
+
assert.match(brief, /smoke: npm run smoke/);
|
|
609
|
+
assert.match(brief, /Stop after 7 iterations or \/ralph-stop/);
|
|
610
|
+
assert.doesNotMatch(brief, /tests: npm test/);
|
|
611
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { SECRET_PATH_POLICY_TOKEN, isSecretBearingPath, matchesProtectedPath } from "../src/secret-paths.ts";
|
|
4
|
+
|
|
5
|
+
test("secret-bearing path detection uses exact rules and ignores similarly named public files", () => {
|
|
6
|
+
for (const path of [
|
|
7
|
+
".env",
|
|
8
|
+
".env.local",
|
|
9
|
+
".npmrc",
|
|
10
|
+
".pypirc",
|
|
11
|
+
".netrc",
|
|
12
|
+
".aws/config",
|
|
13
|
+
".ssh/id_rsa",
|
|
14
|
+
"config/secrets/prod.json",
|
|
15
|
+
"config/credentials/service.json",
|
|
16
|
+
"ops-secrets/config.json",
|
|
17
|
+
"credentials-prod/token.txt",
|
|
18
|
+
"keys/server.pem",
|
|
19
|
+
"keys/private.key",
|
|
20
|
+
"keys/release.asc",
|
|
21
|
+
]) {
|
|
22
|
+
assert.equal(isSecretBearingPath(path), true, path);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for (const path of ["src/secretary.ts", "src/credential-form.tsx"]) {
|
|
26
|
+
assert.equal(isSecretBearingPath(path), false, path);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("policy token protects secret-bearing paths and ignores a non-secret control", () => {
|
|
31
|
+
const protectedFiles = [SECRET_PATH_POLICY_TOKEN];
|
|
32
|
+
|
|
33
|
+
for (const filePath of [
|
|
34
|
+
"credentials/api.json",
|
|
35
|
+
"credentials/payments/service-account.json",
|
|
36
|
+
".ssh/config",
|
|
37
|
+
".npmrc",
|
|
38
|
+
"releases/signing-key.asc",
|
|
39
|
+
".env",
|
|
40
|
+
".env.local",
|
|
41
|
+
]) {
|
|
42
|
+
assert.equal(matchesProtectedPath(filePath, protectedFiles), true, filePath);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
assert.equal(matchesProtectedPath("src/app.ts", protectedFiles), false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("matchesProtectedPath checks repo-relative globs against absolute and relative inputs when cwd is known", () => {
|
|
49
|
+
const cwd = "/repo/project";
|
|
50
|
+
const protectedFiles = ["src/generated/**"];
|
|
51
|
+
|
|
52
|
+
assert.equal(matchesProtectedPath("src/generated/output.ts", protectedFiles, cwd), true);
|
|
53
|
+
assert.equal(matchesProtectedPath("/repo/project/src/generated/output.ts", protectedFiles, cwd), true);
|
|
54
|
+
assert.equal(matchesProtectedPath("/repo/project/src/app.ts", protectedFiles, cwd), false);
|
|
55
|
+
});
|