@lnilluv/pi-ralph-loop 0.2.1 → 0.3.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 +64 -16
- package/package.json +12 -4
- package/src/index.ts +433 -245
- package/src/ralph.ts +642 -0
- package/tests/ralph.test.ts +470 -0
- package/tsconfig.json +3 -2
|
@@ -0,0 +1,470 @@
|
|
|
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
|
+
buildMissionBrief,
|
|
8
|
+
classifyTaskMode,
|
|
9
|
+
createSiblingTarget,
|
|
10
|
+
defaultFrontmatter,
|
|
11
|
+
extractDraftMetadata,
|
|
12
|
+
generateDraft,
|
|
13
|
+
inspectExistingTarget,
|
|
14
|
+
inspectRepo,
|
|
15
|
+
looksLikePath,
|
|
16
|
+
nextSiblingSlug,
|
|
17
|
+
parseCommandArgs,
|
|
18
|
+
parseRalphMarkdown,
|
|
19
|
+
planTaskDraftTarget,
|
|
20
|
+
renderIterationPrompt,
|
|
21
|
+
renderRalphBody,
|
|
22
|
+
resolvePlaceholders,
|
|
23
|
+
slugifyTask,
|
|
24
|
+
shouldValidateExistingDraft,
|
|
25
|
+
validateDraftContent,
|
|
26
|
+
validateFrontmatter,
|
|
27
|
+
} from "../src/ralph.ts";
|
|
28
|
+
import registerRalphCommands, { runCommands } from "../src/index.ts";
|
|
29
|
+
|
|
30
|
+
function createTempDir(): string {
|
|
31
|
+
return mkdtempSync(join(tmpdir(), "pi-ralph-loop-"));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createCommandHarness() {
|
|
35
|
+
const handlers = new Map<string, (args: string, ctx: any) => Promise<string | undefined>>();
|
|
36
|
+
const pi = {
|
|
37
|
+
on: () => undefined,
|
|
38
|
+
registerCommand: (name: string, spec: { handler: (args: string, ctx: any) => Promise<string | undefined> }) => {
|
|
39
|
+
handlers.set(name, spec.handler);
|
|
40
|
+
},
|
|
41
|
+
appendEntry: () => undefined,
|
|
42
|
+
sendUserMessage: () => undefined,
|
|
43
|
+
} as any;
|
|
44
|
+
|
|
45
|
+
registerRalphCommands(pi);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
handler(name: string) {
|
|
49
|
+
const handler = handlers.get(name);
|
|
50
|
+
assert.ok(handler);
|
|
51
|
+
return handler;
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("parseRalphMarkdown falls back to default frontmatter when no frontmatter is present", () => {
|
|
57
|
+
const parsed = parseRalphMarkdown("hello\nworld");
|
|
58
|
+
|
|
59
|
+
assert.deepEqual(parsed.frontmatter, defaultFrontmatter());
|
|
60
|
+
assert.equal(parsed.body, "hello\nworld");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("parseRalphMarkdown parses frontmatter and normalizes line endings", () => {
|
|
64
|
+
const parsed = parseRalphMarkdown(
|
|
65
|
+
"\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",
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
assert.deepEqual(parsed.frontmatter, {
|
|
69
|
+
commands: [{ name: "build", run: "npm test", timeout: 15 }],
|
|
70
|
+
maxIterations: 3,
|
|
71
|
+
timeout: 12.5,
|
|
72
|
+
completionPromise: "done",
|
|
73
|
+
guardrails: { blockCommands: ["rm .*"], protectedFiles: ["src/**"] },
|
|
74
|
+
invalidCommandEntries: undefined,
|
|
75
|
+
});
|
|
76
|
+
assert.equal(parsed.body, "Body\n");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("validateFrontmatter accepts valid input and rejects invalid values", () => {
|
|
80
|
+
assert.equal(validateFrontmatter(defaultFrontmatter()), null);
|
|
81
|
+
assert.equal(
|
|
82
|
+
validateFrontmatter({ ...defaultFrontmatter(), maxIterations: 0 }),
|
|
83
|
+
"Invalid max_iterations: must be a positive finite integer",
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
validateFrontmatter({ ...defaultFrontmatter(), timeout: 0 }),
|
|
87
|
+
"Invalid timeout: must be a positive finite number",
|
|
88
|
+
);
|
|
89
|
+
assert.equal(
|
|
90
|
+
validateFrontmatter({ ...defaultFrontmatter(), guardrails: { blockCommands: ["["], protectedFiles: [] } }),
|
|
91
|
+
"Invalid block_commands regex: [",
|
|
92
|
+
);
|
|
93
|
+
assert.equal(
|
|
94
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "", run: "echo ok", timeout: 1 }] }),
|
|
95
|
+
"Invalid command: name is required",
|
|
96
|
+
);
|
|
97
|
+
assert.equal(
|
|
98
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "", timeout: 1 }] }),
|
|
99
|
+
"Invalid command build: run is required",
|
|
100
|
+
);
|
|
101
|
+
assert.equal(
|
|
102
|
+
validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "echo ok", timeout: 0 }] }),
|
|
103
|
+
"Invalid command build: timeout must be positive",
|
|
104
|
+
);
|
|
105
|
+
assert.equal(
|
|
106
|
+
validateFrontmatter(parseRalphMarkdown("---\ncommands:\n - nope\n - null\n---\nbody").frontmatter),
|
|
107
|
+
"Invalid command entry at index 0",
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("runCommands skips blocked commands before shelling out", async () => {
|
|
112
|
+
const calls: string[] = [];
|
|
113
|
+
const pi = {
|
|
114
|
+
exec: async (_tool: string, args: string[]) => {
|
|
115
|
+
calls.push(args.join(" "));
|
|
116
|
+
return { killed: false, stdout: "allowed", stderr: "" };
|
|
117
|
+
},
|
|
118
|
+
} as any;
|
|
119
|
+
|
|
120
|
+
const outputs = await runCommands(
|
|
121
|
+
[
|
|
122
|
+
{ name: "blocked", run: "git push origin main", timeout: 1 },
|
|
123
|
+
{ name: "allowed", run: "echo ok", timeout: 1 },
|
|
124
|
+
],
|
|
125
|
+
["git\\s+push"],
|
|
126
|
+
pi,
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
assert.deepEqual(outputs, [
|
|
130
|
+
{ name: "blocked", output: "[blocked by guardrail: git\\s+push]" },
|
|
131
|
+
{ name: "allowed", output: "allowed" },
|
|
132
|
+
]);
|
|
133
|
+
assert.deepEqual(calls, ["-c echo ok"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("legacy RALPH.md drafts bypass the generated-draft validation gate", () => {
|
|
137
|
+
assert.equal(shouldValidateExistingDraft("Task body"), false);
|
|
138
|
+
|
|
139
|
+
const draft = generateDraft(
|
|
140
|
+
"Fix flaky auth tests",
|
|
141
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
142
|
+
{ packageManager: "npm", hasGit: false, topLevelDirs: [], topLevelFiles: [] },
|
|
143
|
+
);
|
|
144
|
+
assert.equal(shouldValidateExistingDraft(draft.content), true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("render helpers expand placeholders and strip comments", () => {
|
|
148
|
+
const outputs = [{ name: "build", output: "done" }];
|
|
149
|
+
|
|
150
|
+
assert.equal(
|
|
151
|
+
resolvePlaceholders("{{ commands.build }} {{ ralph.iteration }} {{ ralph.name }} {{ commands.missing }}", outputs, {
|
|
152
|
+
iteration: 7,
|
|
153
|
+
name: "ralph",
|
|
154
|
+
}),
|
|
155
|
+
"done 7 ralph ",
|
|
156
|
+
);
|
|
157
|
+
assert.equal(renderRalphBody("keep<!-- hidden -->{{ ralph.name }}", [], { iteration: 1, name: "ralph" }), "keepralph");
|
|
158
|
+
assert.equal(renderIterationPrompt("Body", 2, 5), "[ralph: iteration 2/5]\n\nBody");
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("parseCommandArgs handles explicit task/path flags and auto mode", () => {
|
|
162
|
+
assert.deepEqual(parseCommandArgs("--task reverse engineer auth"), { mode: "task", value: "reverse engineer auth" });
|
|
163
|
+
assert.deepEqual(parseCommandArgs("--path my-task"), { mode: "path", value: "my-task" });
|
|
164
|
+
assert.deepEqual(parseCommandArgs("--task=fix flaky tests"), { mode: "task", value: "fix flaky tests" });
|
|
165
|
+
assert.deepEqual(parseCommandArgs(" reverse engineer this app "), { mode: "auto", value: "reverse engineer this app" });
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("explicit path mode stays path-centric and does not offer task fallback", async () => {
|
|
169
|
+
const harness = createCommandHarness();
|
|
170
|
+
const handler = harness.handler("ralph");
|
|
171
|
+
const selectOptions: string[][] = [];
|
|
172
|
+
const ctx = {
|
|
173
|
+
cwd: createTempDir(),
|
|
174
|
+
hasUI: true,
|
|
175
|
+
ui: {
|
|
176
|
+
select: async (_title: string, options: string[]) => {
|
|
177
|
+
selectOptions.push(options);
|
|
178
|
+
return "Cancel";
|
|
179
|
+
},
|
|
180
|
+
input: async () => {
|
|
181
|
+
throw new Error("should not prompt for task text");
|
|
182
|
+
},
|
|
183
|
+
notify: () => undefined,
|
|
184
|
+
editor: async () => undefined,
|
|
185
|
+
setStatus: () => undefined,
|
|
186
|
+
},
|
|
187
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
|
|
188
|
+
newSession: async () => ({ cancelled: true }),
|
|
189
|
+
waitForIdle: async () => undefined,
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
await handler("--path reverse engineer auth", ctx);
|
|
193
|
+
|
|
194
|
+
assert.deepEqual(selectOptions, [["Draft in that folder", "Cancel"]]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("path detection and existing-target inspection distinguish runnable Ralph targets from arbitrary markdown", (t) => {
|
|
198
|
+
const cwd = createTempDir();
|
|
199
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
200
|
+
|
|
201
|
+
mkdirSync(join(cwd, "task"), { recursive: true });
|
|
202
|
+
mkdirSync(join(cwd, "empty"), { recursive: true });
|
|
203
|
+
writeFileSync(join(cwd, "task", "RALPH.md"), "Task body", "utf8");
|
|
204
|
+
writeFileSync(join(cwd, "README.md"), "not runnable", "utf8");
|
|
205
|
+
writeFileSync(join(cwd, "package.json"), "{}", "utf8");
|
|
206
|
+
|
|
207
|
+
assert.equal(looksLikePath("reverse engineer auth"), false);
|
|
208
|
+
assert.equal(looksLikePath("auth-audit"), true);
|
|
209
|
+
assert.equal(looksLikePath("README.md"), true);
|
|
210
|
+
assert.equal(looksLikePath("foo/bar"), true);
|
|
211
|
+
assert.equal(looksLikePath("~draft"), false);
|
|
212
|
+
|
|
213
|
+
assert.deepEqual(inspectExistingTarget("task", cwd), { kind: "run", ralphPath: join(cwd, "task", "RALPH.md") });
|
|
214
|
+
assert.deepEqual(inspectExistingTarget("reverse engineer auth", cwd, true), {
|
|
215
|
+
kind: "missing-path",
|
|
216
|
+
dirPath: join(cwd, "reverse engineer auth"),
|
|
217
|
+
ralphPath: join(cwd, "reverse engineer auth", "RALPH.md"),
|
|
218
|
+
});
|
|
219
|
+
assert.deepEqual(inspectExistingTarget("README.md", cwd), { kind: "invalid-markdown", path: join(cwd, "README.md") });
|
|
220
|
+
assert.deepEqual(inspectExistingTarget("package.json", cwd), { kind: "invalid-target", path: join(cwd, "package.json") });
|
|
221
|
+
assert.deepEqual(inspectExistingTarget("empty", cwd), {
|
|
222
|
+
kind: "dir-without-ralph",
|
|
223
|
+
dirPath: join(cwd, "empty"),
|
|
224
|
+
ralphPath: join(cwd, "empty", "RALPH.md"),
|
|
225
|
+
});
|
|
226
|
+
assert.deepEqual(inspectExistingTarget("missing-path", cwd), {
|
|
227
|
+
kind: "missing-path",
|
|
228
|
+
dirPath: join(cwd, "missing-path"),
|
|
229
|
+
ralphPath: join(cwd, "missing-path", "RALPH.md"),
|
|
230
|
+
});
|
|
231
|
+
assert.deepEqual(inspectExistingTarget("foo/bar", cwd), {
|
|
232
|
+
kind: "missing-path",
|
|
233
|
+
dirPath: join(cwd, "foo/bar"),
|
|
234
|
+
ralphPath: join(cwd, "foo/bar", "RALPH.md"),
|
|
235
|
+
});
|
|
236
|
+
assert.deepEqual(inspectExistingTarget("notes.md", cwd), {
|
|
237
|
+
kind: "missing-path",
|
|
238
|
+
dirPath: join(cwd, "notes"),
|
|
239
|
+
ralphPath: join(cwd, "notes", "RALPH.md"),
|
|
240
|
+
});
|
|
241
|
+
assert.deepEqual(inspectExistingTarget("reverse engineer auth", cwd), { kind: "not-path" });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("validateDraftContent rejects missing and malformed frontmatter", () => {
|
|
245
|
+
assert.equal(validateDraftContent("Task body"), "Missing RALPH frontmatter");
|
|
246
|
+
assert.equal(
|
|
247
|
+
validateDraftContent("---\nmax_iterations: 0\n---\nBody"),
|
|
248
|
+
"Invalid max_iterations: must be a positive finite integer",
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("buildMissionBrief fails closed when the current draft content is invalid", () => {
|
|
253
|
+
const plan = generateDraft(
|
|
254
|
+
"Fix flaky auth tests",
|
|
255
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
256
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
const brief = buildMissionBrief({ ...plan, content: "Task: Fix flaky auth tests\n\nThis draft no longer has frontmatter." });
|
|
260
|
+
|
|
261
|
+
assert.match(brief, /Invalid RALPH\.md: Missing RALPH frontmatter/);
|
|
262
|
+
assert.match(brief, /Task metadata missing from current draft|Fix flaky auth tests/);
|
|
263
|
+
assert.doesNotMatch(brief, /Suggested checks/);
|
|
264
|
+
assert.doesNotMatch(brief, /Finish behavior/);
|
|
265
|
+
assert.doesNotMatch(brief, /Safety/);
|
|
266
|
+
assert.doesNotMatch(brief, /tests: npm test/);
|
|
267
|
+
assert.doesNotMatch(brief, /Stop after 25 iterations or \/ralph-stop/);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("slug helpers skip occupied directories when planning siblings", (t) => {
|
|
271
|
+
const cwd = createTempDir();
|
|
272
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
273
|
+
|
|
274
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app"), { recursive: true });
|
|
275
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app-2"), { recursive: true });
|
|
276
|
+
mkdirSync(join(cwd, "reverse-engineer-this-app-3"), { recursive: true });
|
|
277
|
+
|
|
278
|
+
assert.equal(slugifyTask("Reverse engineer this app!"), "reverse-engineer-this-app");
|
|
279
|
+
assert.equal(slugifyTask("!!!"), "ralph-task");
|
|
280
|
+
assert.equal(
|
|
281
|
+
nextSiblingSlug(
|
|
282
|
+
"reverse-engineer-this-app",
|
|
283
|
+
(slug) => slug === "reverse-engineer-this-app-2" || slug === "reverse-engineer-this-app-3",
|
|
284
|
+
),
|
|
285
|
+
"reverse-engineer-this-app-4",
|
|
286
|
+
);
|
|
287
|
+
assert.deepEqual(planTaskDraftTarget(cwd, "Reverse engineer this app"), {
|
|
288
|
+
kind: "conflict",
|
|
289
|
+
target: {
|
|
290
|
+
slug: "reverse-engineer-this-app",
|
|
291
|
+
dirPath: join(cwd, "reverse-engineer-this-app"),
|
|
292
|
+
ralphPath: join(cwd, "reverse-engineer-this-app", "RALPH.md"),
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
assert.equal(createSiblingTarget(cwd, "reverse-engineer-this-app").slug, "reverse-engineer-this-app-4");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("task classification identifies analysis, fix, migration, and general modes", () => {
|
|
299
|
+
assert.equal(classifyTaskMode("Reverse engineer the billing flow"), "analysis");
|
|
300
|
+
assert.equal(classifyTaskMode("Fix flaky auth tests"), "fix");
|
|
301
|
+
assert.equal(classifyTaskMode("Migrate this package to ESM"), "migration");
|
|
302
|
+
assert.equal(classifyTaskMode("Improve the login page"), "general");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
test("inspectRepo detects bounded package signals", (t) => {
|
|
306
|
+
const cwd = createTempDir();
|
|
307
|
+
t.after(() => rmSync(cwd, { recursive: true, force: true }));
|
|
308
|
+
|
|
309
|
+
mkdirSync(join(cwd, ".git"));
|
|
310
|
+
mkdirSync(join(cwd, "src"));
|
|
311
|
+
writeFileSync(join(cwd, "package-lock.json"), "{}", "utf8");
|
|
312
|
+
writeFileSync(
|
|
313
|
+
join(cwd, "package.json"),
|
|
314
|
+
JSON.stringify({ name: "demo", scripts: { test: "vitest", lint: "eslint ." } }, null, 2),
|
|
315
|
+
"utf8",
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
assert.deepEqual(inspectRepo(cwd), {
|
|
319
|
+
packageManager: "npm",
|
|
320
|
+
testCommand: "npm test",
|
|
321
|
+
lintCommand: "npm run lint",
|
|
322
|
+
hasGit: true,
|
|
323
|
+
topLevelDirs: [".git", "src"],
|
|
324
|
+
topLevelFiles: ["package-lock.json", "package.json"],
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("generated drafts reparse as valid RALPH files", () => {
|
|
329
|
+
const draft = generateDraft(
|
|
330
|
+
"Reverse engineer this app",
|
|
331
|
+
{ slug: "reverse-engineer-this-app", dirPath: "/repo/reverse-engineer-this-app", ralphPath: "/repo/reverse-engineer-this-app/RALPH.md" },
|
|
332
|
+
{ packageManager: "npm", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const reparsed = parseRalphMarkdown(draft.content);
|
|
336
|
+
assert.equal(validateFrontmatter(reparsed.frontmatter), null);
|
|
337
|
+
assert.deepEqual(reparsed.frontmatter.commands, [
|
|
338
|
+
{ name: "git-log", run: "git log --oneline -10", timeout: 20 },
|
|
339
|
+
{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
|
|
340
|
+
]);
|
|
341
|
+
assert.deepEqual(reparsed.frontmatter, {
|
|
342
|
+
commands: [
|
|
343
|
+
{ name: "git-log", run: "git log --oneline -10", timeout: 20 },
|
|
344
|
+
{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
|
|
345
|
+
],
|
|
346
|
+
maxIterations: 12,
|
|
347
|
+
timeout: 300,
|
|
348
|
+
completionPromise: undefined,
|
|
349
|
+
guardrails: { blockCommands: ["git\\s+push"], protectedFiles: [] },
|
|
350
|
+
invalidCommandEntries: undefined,
|
|
351
|
+
});
|
|
352
|
+
assert.match(reparsed.body, /Task: Reverse engineer this app/);
|
|
353
|
+
assert.match(reparsed.body, /\{\{ commands.git-log \}\}/);
|
|
354
|
+
assert.match(reparsed.body, /\{\{ ralph.iteration \}\}/);
|
|
355
|
+
assert.equal(extractDraftMetadata(draft.content)?.mode, "analysis");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("generated draft starts fail closed when validation no longer passes", async () => {
|
|
359
|
+
const cwd = createTempDir();
|
|
360
|
+
const targetDir = join(cwd, "generated-draft");
|
|
361
|
+
const ralphPath = join(targetDir, "RALPH.md");
|
|
362
|
+
mkdirSync(targetDir, { recursive: true });
|
|
363
|
+
const draft = generateDraft(
|
|
364
|
+
"Fix flaky auth tests",
|
|
365
|
+
{ slug: "generated-draft", dirPath: targetDir, ralphPath },
|
|
366
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
367
|
+
);
|
|
368
|
+
writeFileSync(ralphPath, draft.content.replace("max_iterations: 25", "max_iterations: 0"), "utf8");
|
|
369
|
+
|
|
370
|
+
const notifications: Array<{ level: string; message: string }> = [];
|
|
371
|
+
const harness = createCommandHarness();
|
|
372
|
+
const handler = harness.handler("ralph");
|
|
373
|
+
const ctx = {
|
|
374
|
+
cwd,
|
|
375
|
+
hasUI: true,
|
|
376
|
+
ui: {
|
|
377
|
+
notify: (message: string, level: string) => notifications.push({ level, message }),
|
|
378
|
+
select: async () => {
|
|
379
|
+
throw new Error("should not prompt");
|
|
380
|
+
},
|
|
381
|
+
input: async () => {
|
|
382
|
+
throw new Error("should not prompt");
|
|
383
|
+
},
|
|
384
|
+
editor: async () => undefined,
|
|
385
|
+
setStatus: () => undefined,
|
|
386
|
+
},
|
|
387
|
+
sessionManager: { getEntries: () => [], getSessionFile: () => undefined },
|
|
388
|
+
newSession: async () => {
|
|
389
|
+
throw new Error("should not start");
|
|
390
|
+
},
|
|
391
|
+
waitForIdle: async () => undefined,
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
await handler(`--path ${ralphPath}`, ctx);
|
|
395
|
+
|
|
396
|
+
assert.deepEqual(notifications, [{ level: "error", message: "Invalid RALPH.md: Invalid max_iterations: must be a positive finite integer" }]);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
test("generateDraft creates metadata-rich analysis and fix drafts", () => {
|
|
400
|
+
const analysisDraft = generateDraft(
|
|
401
|
+
"Reverse engineer this app",
|
|
402
|
+
{ slug: "reverse-engineer-this-app", dirPath: "/repo/reverse-engineer-this-app", ralphPath: "/repo/reverse-engineer-this-app/RALPH.md" },
|
|
403
|
+
{ packageManager: "npm", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
404
|
+
);
|
|
405
|
+
assert.equal(analysisDraft.mode, "analysis");
|
|
406
|
+
assert.equal(extractDraftMetadata(analysisDraft.content)?.mode, "analysis");
|
|
407
|
+
assert.match(analysisDraft.content, /Start with read-only inspection/);
|
|
408
|
+
assert.match(analysisDraft.content, /\{\{ commands.repo-map \}\}/);
|
|
409
|
+
assert.equal(analysisDraft.safetyLabel, "blocks git push");
|
|
410
|
+
assert.doesNotMatch(analysisDraft.content, /\*\*\/\*/);
|
|
411
|
+
const analysisBrief = buildMissionBrief(analysisDraft);
|
|
412
|
+
assert.match(analysisBrief, /- blocks git push/);
|
|
413
|
+
assert.doesNotMatch(analysisBrief, /read-only/);
|
|
414
|
+
|
|
415
|
+
const fixDraft = generateDraft(
|
|
416
|
+
"Fix flaky auth tests",
|
|
417
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
418
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
419
|
+
);
|
|
420
|
+
assert.equal(fixDraft.mode, "fix");
|
|
421
|
+
assert.match(fixDraft.content, /If tests or lint are failing/);
|
|
422
|
+
assert.match(fixDraft.content, /\{\{ commands.tests \}\}/);
|
|
423
|
+
assert.match(fixDraft.content, /\{\{ commands.lint \}\}/);
|
|
424
|
+
assert.equal(extractDraftMetadata(fixDraft.content)?.task, "Fix flaky auth tests");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("generated draft metadata survives task text containing HTML comment markers", () => {
|
|
428
|
+
const task = "Reverse engineer the parser <!-- tricky --> and document the edge case";
|
|
429
|
+
const draft = generateDraft(
|
|
430
|
+
task,
|
|
431
|
+
{
|
|
432
|
+
slug: "reverse-engineer-the-parser-and-document-the-edge-case",
|
|
433
|
+
dirPath: "/repo/reverse-engineer-the-parser-and-document-the-edge-case",
|
|
434
|
+
ralphPath: "/repo/reverse-engineer-the-parser-and-document-the-edge-case/RALPH.md",
|
|
435
|
+
},
|
|
436
|
+
{ packageManager: "npm", hasGit: false, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
|
|
437
|
+
);
|
|
438
|
+
const parsed = parseRalphMarkdown(draft.content);
|
|
439
|
+
|
|
440
|
+
assert.equal(extractDraftMetadata(draft.content)?.task, task);
|
|
441
|
+
assert.equal(validateDraftContent(draft.content), null);
|
|
442
|
+
assert.match(draft.content, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
443
|
+
assert.match(parsed.body, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
444
|
+
const rendered = renderRalphBody(parsed.body, [], { iteration: 1, name: "ralph" });
|
|
445
|
+
assert.match(rendered, /Task: Reverse engineer the parser <!-- tricky --> and document the edge case/);
|
|
446
|
+
assert.doesNotMatch(rendered, /<!-- tricky -->/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("buildMissionBrief refreshes after draft edits", () => {
|
|
450
|
+
const plan = generateDraft(
|
|
451
|
+
"Fix flaky auth tests",
|
|
452
|
+
{ slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
|
|
453
|
+
{ packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: false, topLevelDirs: [], topLevelFiles: [] },
|
|
454
|
+
);
|
|
455
|
+
const editedPlan = {
|
|
456
|
+
...plan,
|
|
457
|
+
content: plan.content
|
|
458
|
+
.replace("Task: Fix flaky auth tests", "Task: Fix flaky auth regressions")
|
|
459
|
+
.replace("name: tests\n run: npm test\n timeout: 120", "name: smoke\n run: npm run smoke\n timeout: 45")
|
|
460
|
+
.replace("max_iterations: 25", "max_iterations: 7"),
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
const brief = buildMissionBrief(editedPlan);
|
|
464
|
+
assert.match(brief, /Mission Brief/);
|
|
465
|
+
assert.match(brief, /Fix flaky auth regressions/);
|
|
466
|
+
assert.doesNotMatch(brief, /Fix flaky auth tests/);
|
|
467
|
+
assert.match(brief, /smoke: npm run smoke/);
|
|
468
|
+
assert.match(brief, /Stop after 7 iterations or \/ralph-stop/);
|
|
469
|
+
assert.doesNotMatch(brief, /tests: npm test/);
|
|
470
|
+
});
|