@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
@@ -4,12 +4,18 @@ import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  import test from "node:test";
6
6
  import {
7
+ buildDraftRequest,
7
8
  buildMissionBrief,
9
+ buildRepoContext,
8
10
  classifyTaskMode,
9
11
  createSiblingTarget,
10
12
  defaultFrontmatter,
11
13
  extractDraftMetadata,
12
14
  generateDraft,
15
+ isWeakStrengthenedDraft,
16
+ acceptStrengthenedDraft,
17
+ normalizeStrengthenedDraft,
18
+ inspectDraftContent,
13
19
  inspectExistingTarget,
14
20
  inspectRepo,
15
21
  looksLikePath,
@@ -20,17 +26,25 @@ import {
20
26
  renderIterationPrompt,
21
27
  renderRalphBody,
22
28
  resolvePlaceholders,
29
+ resolveCommandRun,
30
+ runtimeArgEntriesToMap,
23
31
  slugifyTask,
24
32
  shouldValidateExistingDraft,
25
33
  validateDraftContent,
26
34
  validateFrontmatter,
27
35
  } from "../src/ralph.ts";
36
+ import { SECRET_PATH_POLICY_TOKEN } from "../src/secret-paths.ts";
37
+ import type { RepoSignals } from "../src/ralph.ts";
28
38
  import registerRalphCommands, { runCommands } from "../src/index.ts";
29
39
 
30
40
  function createTempDir(): string {
31
41
  return mkdtempSync(join(tmpdir(), "pi-ralph-loop-"));
32
42
  }
33
43
 
44
+ function encodeMetadata(metadata: Record<string, unknown>): string {
45
+ return `<!-- pi-ralph-loop: ${encodeURIComponent(JSON.stringify(metadata))} -->`;
46
+ }
47
+
34
48
  function createCommandHarness() {
35
49
  const handlers = new Map<string, (args: string, ctx: any) => Promise<string | undefined>>();
36
50
  const pi = {
@@ -53,6 +67,41 @@ function createCommandHarness() {
53
67
  };
54
68
  }
55
69
 
70
+ function assertMetadataSource(metadata: ReturnType<typeof extractDraftMetadata>, expected: "deterministic" | "llm-strengthened" | "fallback") {
71
+ if (!metadata || !("source" in metadata)) {
72
+ assert.fail("Expected draft metadata with a source");
73
+ }
74
+ assert.equal(metadata.source, expected);
75
+ }
76
+
77
+ function makeFixRequest() {
78
+ return buildDraftRequest(
79
+ "Fix flaky auth tests",
80
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
81
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
82
+ );
83
+ }
84
+
85
+ function makeFixRequestWithArgs(args: readonly string[]) {
86
+ const request = makeFixRequest();
87
+ return {
88
+ ...request,
89
+ baselineDraft: request.baselineDraft.replace(`commands:\n`, `args:\n - ${args.join("\n - ")}\ncommands:\n`),
90
+ };
91
+ }
92
+
93
+ function makeFixRequestWithCompletionPromise(completionPromise: string) {
94
+ const request = makeFixRequest();
95
+ return {
96
+ ...request,
97
+ baselineDraft: request.baselineDraft.replace("timeout: 300\n", `completion_promise: ${completionPromise}\ntimeout: 300\n`),
98
+ };
99
+ }
100
+
101
+ function makeStrengthenedDraft(frontmatterLines: readonly string[], body: string, task = "Fix flaky auth tests") {
102
+ return `${encodeMetadata({ generator: "pi-ralph-loop", version: 2, source: "llm-strengthened", task, mode: "fix" })}\n---\n${frontmatterLines.join("\n")}\n---\n${body}`;
103
+ }
104
+
56
105
  test("parseRalphMarkdown falls back to default frontmatter when no frontmatter is present", () => {
57
106
  const parsed = parseRalphMarkdown("hello\nworld");
58
107
 
@@ -62,55 +111,372 @@ test("parseRalphMarkdown falls back to default frontmatter when no frontmatter i
62
111
 
63
112
  test("parseRalphMarkdown parses frontmatter and normalizes line endings", () => {
64
113
  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",
114
+ "\uFEFF---\r\ncommands:\r\n - name: build\r\n run: npm test\r\n timeout: 15\r\nmax_iterations: 3\r\ninter_iteration_delay: 7\r\ntimeout: 12.5\r\nrequired_outputs:\r\n - docs/ARCHITECTURE.md\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
115
  );
67
116
 
68
117
  assert.deepEqual(parsed.frontmatter, {
69
118
  commands: [{ name: "build", run: "npm test", timeout: 15 }],
70
119
  maxIterations: 3,
120
+ interIterationDelay: 7,
71
121
  timeout: 12.5,
72
122
  completionPromise: "done",
123
+ requiredOutputs: ["docs/ARCHITECTURE.md"],
124
+ stopOnError: true,
73
125
  guardrails: { blockCommands: ["rm .*"], protectedFiles: ["src/**"] },
74
126
  invalidCommandEntries: undefined,
75
127
  });
76
128
  assert.equal(parsed.body, "Body\n");
77
129
  });
78
130
 
79
- test("validateFrontmatter accepts valid input and rejects invalid values", () => {
131
+ test("parseRalphMarkdown parses declared args as runtime parameters", () => {
132
+ const parsed = parseRalphMarkdown(
133
+ "---\nargs:\n - owner\n - mode\ncommands: []\nmax_iterations: 1\ntimeout: 1\nguardrails:\n block_commands: []\n protected_files: []\n---\nBody\n",
134
+ );
135
+
136
+ assert.deepEqual(parsed.frontmatter.args, ["owner", "mode"]);
137
+ assert.equal(validateFrontmatter(parsed.frontmatter), null);
138
+ });
139
+
140
+ test("parseRalphMarkdown parses stop_on_error from frontmatter", () => {
141
+ const parsed = parseRalphMarkdown("---\nstop_on_error: false\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
142
+ assert.equal(parsed.frontmatter.stopOnError, false);
143
+ });
144
+
145
+ test("parseRalphMarkdown defaults stop_on_error to true", () => {
146
+ const parsed = parseRalphMarkdown("---\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
147
+ assert.equal(parsed.frontmatter.stopOnError, true);
148
+ });
149
+
150
+ test("parseRalphMarkdown treats non-false stop_on_error as true (safe default)", () => {
151
+ const parsed = parseRalphMarkdown("---\nstop_on_error: yes\nmax_iterations: 5\ntimeout: 60\ncommands: []\nguardrails: { block_commands: [], protected_files: [] }\n---\nTask\n");
152
+ assert.equal(parsed.frontmatter.stopOnError, true);
153
+ });
154
+
155
+ test("validateFrontmatter accepts valid input and rejects invalid bounds, names, args, and globs", () => {
80
156
  assert.equal(validateFrontmatter(defaultFrontmatter()), null);
157
+ assert.equal(
158
+ validateFrontmatter({ ...defaultFrontmatter(), args: ["owner"] }),
159
+ null,
160
+ );
161
+ assert.equal(
162
+ validateFrontmatter({ ...defaultFrontmatter(), args: ["owner", "owner"] }),
163
+ "Invalid args: names must be unique",
164
+ );
165
+ assert.equal(
166
+ validateFrontmatter({ ...defaultFrontmatter(), args: ["build now"] }),
167
+ "Invalid arg name: build now must match ^\\w[\\w-]*$",
168
+ );
81
169
  assert.equal(
82
170
  validateFrontmatter({ ...defaultFrontmatter(), maxIterations: 0 }),
83
- "Invalid max_iterations: must be a positive finite integer",
171
+ "Invalid max_iterations: must be between 1 and 50",
172
+ );
173
+ assert.equal(
174
+ validateFrontmatter({ ...defaultFrontmatter(), maxIterations: 51 }),
175
+ "Invalid max_iterations: must be between 1 and 50",
176
+ );
177
+ assert.equal(
178
+ validateFrontmatter({ ...defaultFrontmatter(), interIterationDelay: 3 }),
179
+ null,
180
+ );
181
+ assert.equal(
182
+ validateFrontmatter({ ...defaultFrontmatter(), interIterationDelay: -1 }),
183
+ "Invalid inter_iteration_delay: must be a non-negative integer",
184
+ );
185
+ assert.equal(
186
+ validateFrontmatter({ ...defaultFrontmatter(), interIterationDelay: 1.5 }),
187
+ "Invalid inter_iteration_delay: must be a non-negative integer",
84
188
  );
85
189
  assert.equal(
86
190
  validateFrontmatter({ ...defaultFrontmatter(), timeout: 0 }),
87
- "Invalid timeout: must be a positive finite number",
191
+ "Invalid timeout: must be greater than 0 and at most 300",
192
+ );
193
+ assert.equal(
194
+ validateFrontmatter({ ...defaultFrontmatter(), timeout: 301 }),
195
+ "Invalid timeout: must be greater than 0 and at most 300",
88
196
  );
89
197
  assert.equal(
90
198
  validateFrontmatter({ ...defaultFrontmatter(), guardrails: { blockCommands: ["["], protectedFiles: [] } }),
91
199
  "Invalid block_commands regex: [",
92
200
  );
201
+ assert.equal(
202
+ validateFrontmatter({ ...defaultFrontmatter(), guardrails: { blockCommands: [], protectedFiles: ["**/*"] } }),
203
+ "Invalid protected_files glob: **/*",
204
+ );
93
205
  assert.equal(
94
206
  validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "", run: "echo ok", timeout: 1 }] }),
95
207
  "Invalid command: name is required",
96
208
  );
209
+ assert.equal(
210
+ validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build now", run: "echo ok", timeout: 1 }] }),
211
+ "Invalid command name: build now must match ^\\w[\\w-]*$",
212
+ );
97
213
  assert.equal(
98
214
  validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "", timeout: 1 }] }),
99
215
  "Invalid command build: run is required",
100
216
  );
101
217
  assert.equal(
102
218
  validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "echo ok", timeout: 0 }] }),
103
- "Invalid command build: timeout must be positive",
219
+ "Invalid command build: timeout must be greater than 0 and at most 300",
220
+ );
221
+ assert.equal(
222
+ validateFrontmatter({ ...defaultFrontmatter(), commands: [{ name: "build", run: "echo ok", timeout: 301 }] }),
223
+ "Invalid command build: timeout must be greater than 0 and at most 300",
224
+ );
225
+ assert.equal(
226
+ validateFrontmatter({ ...defaultFrontmatter(), timeout: 20, commands: [{ name: "build", run: "echo ok", timeout: 21 }] }),
227
+ "Invalid command build: timeout must not exceed top-level timeout",
104
228
  );
105
229
  assert.equal(
106
230
  validateFrontmatter(parseRalphMarkdown("---\ncommands:\n - nope\n - null\n---\nbody").frontmatter),
107
231
  "Invalid command entry at index 0",
108
232
  );
233
+ assert.equal(
234
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: [""] }),
235
+ "Invalid required_outputs entry: must be a relative file path",
236
+ );
237
+ assert.equal(
238
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["/abs.md"] }),
239
+ "Invalid required_outputs entry: /abs.md must be a relative file path",
240
+ );
241
+ assert.equal(
242
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["../oops.md"] }),
243
+ "Invalid required_outputs entry: ../oops.md must be a relative file path",
244
+ );
245
+ assert.equal(
246
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["docs/"] }),
247
+ "Invalid required_outputs entry: docs/ must be a relative file path",
248
+ );
249
+ assert.equal(
250
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["./file.md"] }),
251
+ "Invalid required_outputs entry: ./file.md must be a relative file path",
252
+ );
253
+ assert.equal(
254
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["docs/./guide.md"] }),
255
+ "Invalid required_outputs entry: docs/./guide.md must be a relative file path",
256
+ );
257
+ assert.equal(
258
+ validateFrontmatter({ ...defaultFrontmatter(), requiredOutputs: ["docs/\nREADME.md"] }),
259
+ "Invalid required_outputs entry: docs/\nREADME.md must be a relative file path",
260
+ );
261
+ });
262
+
263
+ test("validateFrontmatter accepts stop_on_error true and false", () => {
264
+ const fmTrue = { ...defaultFrontmatter(), stopOnError: true };
265
+ const fmFalse = { ...defaultFrontmatter(), stopOnError: false };
266
+ assert.equal(validateFrontmatter(fmTrue), null);
267
+ assert.equal(validateFrontmatter(fmFalse), null);
268
+ });
269
+
270
+ test("validateFrontmatter rejects unsafe completion_promise values and Mission Brief fails closed", () => {
271
+ assert.equal(
272
+ validateFrontmatter({ ...defaultFrontmatter(), completionPromise: "ready\nnow" }),
273
+ "Invalid completion_promise: must be a single-line string without line breaks or angle brackets",
274
+ );
275
+ assert.equal(
276
+ validateFrontmatter({ ...defaultFrontmatter(), completionPromise: "<promise>ready</promise>" }),
277
+ "Invalid completion_promise: must be a single-line string without line breaks or angle brackets",
278
+ );
279
+
280
+ const plan = generateDraft(
281
+ "Fix flaky auth tests",
282
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
283
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
284
+ );
285
+ const brief = buildMissionBrief({
286
+ ...plan,
287
+ content: plan.content.replace(
288
+ "timeout: 300\n",
289
+ "timeout: 45\ncompletion_promise: |\n ready\n now\n",
290
+ ),
291
+ });
292
+
293
+ assert.match(brief, /^Mission Brief/m);
294
+ assert.match(brief, /Invalid RALPH\.md: Invalid completion_promise: must be a single-line string without line breaks or angle brackets/);
295
+ assert.match(brief, /^Draft status$/m);
296
+ assert.doesNotMatch(brief, /Finish behavior/);
297
+ assert.doesNotMatch(brief, /<promise>/);
298
+ });
299
+
300
+ test("inspectDraftContent, validateDraftContent, and Mission Brief fail closed on raw invalid completion_promise values", () => {
301
+ const plan = generateDraft(
302
+ "Fix flaky auth tests",
303
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
304
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
305
+ );
306
+
307
+ for (const [label, rawValue] of [
308
+ ["array", "completion_promise: [oops]"],
309
+ ["number", "completion_promise: 7"],
310
+ ["blank string", 'completion_promise: ""'],
311
+ ] as const) {
312
+ const raw = plan.content.replace("timeout: 300\n", `${rawValue}\ntimeout: 300\n`);
313
+
314
+ assert.equal(
315
+ inspectDraftContent(raw).error,
316
+ "Invalid completion_promise: must be a single-line string without line breaks or angle brackets",
317
+ label,
318
+ );
319
+ assert.equal(
320
+ validateDraftContent(raw),
321
+ "Invalid completion_promise: must be a single-line string without line breaks or angle brackets",
322
+ label,
323
+ );
324
+
325
+ const brief = buildMissionBrief({ ...plan, content: raw });
326
+ assert.match(brief, /^Mission Brief/m, label);
327
+ assert.match(brief, /Invalid RALPH\.md: Invalid completion_promise: must be a single-line string without line breaks or angle brackets/, label);
328
+ assert.doesNotMatch(brief, /Finish behavior/, label);
329
+ assert.doesNotMatch(brief, /<promise>/, label);
330
+ }
331
+ });
332
+
333
+ test("acceptStrengthenedDraft rejects required_outputs changes", () => {
334
+ const request = makeFixRequest();
335
+ const strengthenedDraft = makeStrengthenedDraft(
336
+ [
337
+ "commands:",
338
+ " - name: tests",
339
+ " run: npm test",
340
+ " timeout: 20",
341
+ "max_iterations: 20",
342
+ "timeout: 120",
343
+ "required_outputs:",
344
+ " - ARCHITECTURE.md",
345
+ "guardrails:",
346
+ " block_commands:",
347
+ " - 'git\\s+push'",
348
+ " protected_files:",
349
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
350
+ ],
351
+ "Task: Fix flaky auth tests\n\nKeep the change small.",
352
+ );
353
+
354
+ assert.equal(acceptStrengthenedDraft(request, strengthenedDraft), null);
355
+ });
356
+
357
+ test("inspectDraftContent, validateDraftContent, and Mission Brief fail closed on raw malformed guardrails values", () => {
358
+ const plan = generateDraft(
359
+ "Fix flaky auth tests",
360
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
361
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
362
+ );
363
+
364
+ for (const { label, frontmatterLines, expectedError, briefError } of [
365
+ {
366
+ label: "block_commands scalar",
367
+ frontmatterLines: [
368
+ "commands: []",
369
+ "max_iterations: 25",
370
+ "timeout: 300",
371
+ "guardrails:",
372
+ " block_commands: 'git\\s+push'",
373
+ " protected_files: []",
374
+ ],
375
+ expectedError: "Invalid RALPH frontmatter: guardrails.block_commands must be a YAML sequence",
376
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: guardrails\.block_commands must be a YAML sequence/,
377
+ },
378
+ {
379
+ label: "block_commands null",
380
+ frontmatterLines: [
381
+ "commands: []",
382
+ "max_iterations: 25",
383
+ "timeout: 300",
384
+ "guardrails:",
385
+ " block_commands: null",
386
+ " protected_files: []",
387
+ ],
388
+ expectedError: "Invalid RALPH frontmatter: guardrails.block_commands must be a YAML sequence",
389
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: guardrails\.block_commands must be a YAML sequence/,
390
+ },
391
+ {
392
+ label: "protected_files scalar",
393
+ frontmatterLines: [
394
+ "commands: []",
395
+ "max_iterations: 25",
396
+ "timeout: 300",
397
+ "guardrails:",
398
+ " block_commands: []",
399
+ " protected_files: 'src/generated/**'",
400
+ ],
401
+ expectedError: "Invalid RALPH frontmatter: guardrails.protected_files must be a YAML sequence",
402
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: guardrails\.protected_files must be a YAML sequence/,
403
+ },
404
+ {
405
+ label: "protected_files null",
406
+ frontmatterLines: [
407
+ "commands: []",
408
+ "max_iterations: 25",
409
+ "timeout: 300",
410
+ "guardrails:",
411
+ " block_commands: []",
412
+ " protected_files: null",
413
+ ],
414
+ expectedError: "Invalid RALPH frontmatter: guardrails.protected_files must be a YAML sequence",
415
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: guardrails\.protected_files must be a YAML sequence/,
416
+ },
417
+ ] as const) {
418
+ const raw = makeStrengthenedDraft(frontmatterLines, "Task: Fix flaky auth tests\n\nKeep the change small.");
419
+ const inspection = inspectDraftContent(raw);
420
+
421
+ assert.equal(inspection.error, expectedError, label);
422
+ assert.equal(validateDraftContent(raw), expectedError, label);
423
+
424
+ const brief = buildMissionBrief({ ...plan, content: raw });
425
+ assert.match(brief, /^Mission Brief/m, label);
426
+ assert.match(brief, briefError, label);
427
+ assert.doesNotMatch(brief, /Finish behavior/, label);
428
+ assert.doesNotMatch(brief, /<promise>/, label);
429
+ }
430
+ });
431
+
432
+ test("acceptStrengthenedDraft rejects raw malformed guardrails shapes", () => {
433
+ const request = makeFixRequest();
434
+
435
+ for (const { label, frontmatterLines } of [
436
+ {
437
+ label: "block_commands scalar",
438
+ frontmatterLines: [
439
+ "commands:",
440
+ " - name: tests",
441
+ " run: npm test",
442
+ " timeout: 20",
443
+ "max_iterations: 20",
444
+ "timeout: 120",
445
+ "guardrails:",
446
+ " block_commands: 'git\\s+push'",
447
+ " protected_files:",
448
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
449
+ ],
450
+ },
451
+ {
452
+ label: "protected_files scalar",
453
+ frontmatterLines: [
454
+ "commands:",
455
+ " - name: tests",
456
+ " run: npm test",
457
+ " timeout: 20",
458
+ "max_iterations: 20",
459
+ "timeout: 120",
460
+ "guardrails:",
461
+ " block_commands:",
462
+ " - 'git\\s+push'",
463
+ " protected_files: 'src/generated/**'",
464
+ ],
465
+ },
466
+ ] as const) {
467
+ const strengthenedDraft = makeStrengthenedDraft(frontmatterLines, "Task: Fix flaky auth tests\n\nKeep the change small.");
468
+
469
+ assert.equal(acceptStrengthenedDraft(request, strengthenedDraft), null, label);
470
+ }
109
471
  });
110
472
 
111
473
  test("runCommands skips blocked commands before shelling out", async () => {
112
474
  const calls: string[] = [];
475
+ const proofEntries: Array<{ customType: string; data: any }> = [];
113
476
  const pi = {
477
+ appendEntry: (customType: string, data: any) => {
478
+ proofEntries.push({ customType, data });
479
+ },
114
480
  exec: async (_tool: string, args: string[]) => {
115
481
  calls.push(args.join(" "));
116
482
  return { killed: false, stdout: "allowed", stderr: "" };
@@ -131,6 +497,46 @@ test("runCommands skips blocked commands before shelling out", async () => {
131
497
  { name: "allowed", output: "allowed" },
132
498
  ]);
133
499
  assert.deepEqual(calls, ["-c echo ok"]);
500
+ assert.equal(proofEntries.length, 1);
501
+ assert.equal(proofEntries[0].customType, "ralph-blocked-command");
502
+ assert.equal(proofEntries[0].data.command, "git push origin main");
503
+ });
504
+
505
+ test("runCommands resolves args before shelling out", async () => {
506
+ const calls: string[] = [];
507
+ const pi = {
508
+ exec: async (_tool: string, args: string[]) => {
509
+ calls.push(args.join(" "));
510
+ return { killed: false, stdout: "ok", stderr: "" };
511
+ },
512
+ } as any;
513
+
514
+ const outputs = await runCommands(
515
+ [{ name: "greet", run: "echo {{ args.owner }}", timeout: 1 }],
516
+ [],
517
+ pi,
518
+ { owner: "Ada" },
519
+ );
520
+
521
+ assert.deepEqual(outputs, [{ name: "greet", output: "ok" }]);
522
+ assert.deepEqual(calls, ["-c echo 'Ada'"]);
523
+ });
524
+
525
+ test("runCommands blocks anchored guardrails even when the first token comes from an arg", async () => {
526
+ const pi = {
527
+ exec: async () => {
528
+ throw new Error("should not execute blocked command");
529
+ },
530
+ } as any;
531
+
532
+ const outputs = await runCommands(
533
+ [{ name: "blocked", run: "{{ args.tool }} hello", timeout: 1 }],
534
+ ["^printf\\b"],
535
+ pi,
536
+ { tool: "printf" },
537
+ );
538
+
539
+ assert.deepEqual(outputs, [{ name: "blocked", output: "[blocked by guardrail: ^printf\\b]" }]);
134
540
  });
135
541
 
136
542
  test("legacy RALPH.md drafts bypass the generated-draft validation gate", () => {
@@ -144,25 +550,170 @@ test("legacy RALPH.md drafts bypass the generated-draft validation gate", () =>
144
550
  assert.equal(shouldValidateExistingDraft(draft.content), true);
145
551
  });
146
552
 
147
- test("render helpers expand placeholders and strip comments", () => {
553
+ test("inspectDraftContent rejects malformed args frontmatter shapes", () => {
554
+ const invalid = inspectDraftContent(["---", "args: owner", "commands: []", "max_iterations: 1", "timeout: 1", "guardrails:", " block_commands: []", " protected_files: []", "---", "Body"].join("\n"));
555
+
556
+ assert.equal(invalid.error, "Invalid RALPH frontmatter: args must be a YAML sequence");
557
+ });
558
+
559
+ test("inspectDraftContent rejects malformed inter_iteration_delay frontmatter shapes", () => {
560
+ const invalid = inspectDraftContent(["---", "commands: []", "max_iterations: 1", "inter_iteration_delay: true", "timeout: 1", "guardrails:", " block_commands: []", " protected_files: []", "---", "Body"].join("\n"));
561
+
562
+ assert.equal(invalid.error, "Invalid RALPH frontmatter: inter_iteration_delay must be a YAML number");
563
+ });
564
+
565
+ test("render helpers expand placeholders, keep body text plain, and shell-quote command args", () => {
148
566
  const outputs = [{ name: "build", output: "done" }];
149
567
 
150
568
  assert.equal(
151
- resolvePlaceholders("{{ commands.build }} {{ ralph.iteration }} {{ ralph.name }} {{ commands.missing }}", outputs, {
569
+ resolvePlaceholders("{{ commands.build }} {{ ralph.iteration }} {{ ralph.name }} {{ ralph.max_iterations }} {{ args.owner }} {{ commands.missing }}", outputs, {
152
570
  iteration: 7,
153
571
  name: "ralph",
154
- }),
155
- "done 7 ralph ",
572
+ maxIterations: 12,
573
+ }, { owner: "Ada" }),
574
+ "done 7 ralph 12 Ada ",
156
575
  );
157
- assert.equal(renderRalphBody("keep<!-- hidden -->{{ ralph.name }}", [], { iteration: 1, name: "ralph" }), "keepralph");
576
+ assert.equal(
577
+ renderRalphBody("keep<!-- hidden -->{{ args.owner }}{{ ralph.name }}", [], { iteration: 1, name: "ralph", maxIterations: 1 }, { owner: "Ada; echo injected" }),
578
+ "keepAda; echo injectedralph",
579
+ );
580
+ assert.equal(resolveCommandRun("npm run {{ args.script }}", { script: "test" }), "npm run 'test'");
581
+ assert.equal(resolveCommandRun("echo {{ args.owner }}", { owner: "Ada; echo injected" }), "echo 'Ada; echo injected'");
582
+ assert.throws(() => resolveCommandRun("npm run {{ args.missing }}", { script: "test" }), /Missing required arg: missing/);
158
583
  assert.equal(renderIterationPrompt("Body", 2, 5), "[ralph: iteration 2/5]\n\nBody");
159
584
  });
160
585
 
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" });
586
+ test("resolvePlaceholders leaves command output placeholders literal", () => {
587
+ const outputs = [{ name: "build", output: "echo {{ args.owner }}" }];
588
+
589
+ assert.equal(
590
+ resolvePlaceholders("{{ commands.build }} {{ args.owner }}", outputs, {
591
+ iteration: 7,
592
+ name: "ralph",
593
+ maxIterations: 12,
594
+ }, { owner: "Ada" }),
595
+ "echo {{ args.owner }} Ada",
596
+ );
597
+ });
598
+
599
+ test("renderIterationPrompt includes completion-gate reminders and previous failure reasons", () => {
600
+ const prompt = renderIterationPrompt("Body", 2, 5, {
601
+ completionPromise: "DONE",
602
+ requiredOutputs: ["ARCHITECTURE.md", "OPEN_QUESTIONS.md"],
603
+ failureReasons: ["Missing required output: ARCHITECTURE.md", "OPEN_QUESTIONS.md still has P0 items"],
604
+ });
605
+
606
+ assert.match(prompt, /Required outputs must exist before stopping: ARCHITECTURE\.md, OPEN_QUESTIONS\.md/);
607
+ assert.match(prompt, /OPEN_QUESTIONS\.md must have no remaining P0\/P1 items before stopping\./);
608
+ assert.match(prompt, /Label inferred claims as HYPOTHESIS\./);
609
+ assert.match(prompt, /Previous gate failures: Missing required output: ARCHITECTURE\.md; OPEN_QUESTIONS\.md still has P0 items/);
610
+ assert.match(prompt, /Emit <promise>DONE<\/promise> only when the gate is truly satisfied\./);
611
+ });
612
+
613
+ test("renderIterationPrompt includes a rejection section when durable progress is still missing", () => {
614
+ const prompt = renderIterationPrompt("Body", 2, 5, {
615
+ completionPromise: "DONE",
616
+ requiredOutputs: ["ARCHITECTURE.md"],
617
+ rejectionReasons: ["durable progress"],
618
+ });
619
+
620
+ assert.match(prompt, /\[completion gate rejection\]/);
621
+ assert.match(prompt, /Still missing: durable progress/);
622
+ assert.match(prompt, /Emit <promise>DONE<\/promise> only when the gate is truly satisfied\./);
623
+ });
624
+
625
+ test("parseCommandArgs handles explicit path args, leaves task text alone, and rejects task args", () => {
626
+ assert.deepEqual(parseCommandArgs("--path my-task"), { mode: "path", value: "my-task", runtimeArgs: [], error: undefined });
627
+ assert.deepEqual(parseCommandArgs("--path my-task --arg owner=Ada --arg mode=fix"), {
628
+ mode: "path",
629
+ value: "my-task",
630
+ runtimeArgs: [
631
+ { name: "owner", value: "Ada" },
632
+ { name: "mode", value: "fix" },
633
+ ],
634
+ error: undefined,
635
+ });
636
+ assert.deepEqual(parseCommandArgs(" reverse engineer this app "), { mode: "auto", value: "reverse engineer this app", runtimeArgs: [], error: undefined });
637
+ assert.deepEqual(parseCommandArgs("reverse engineer --arg name=value literally"), {
638
+ mode: "auto",
639
+ value: "reverse engineer --arg name=value literally",
640
+ runtimeArgs: [],
641
+ error: undefined,
642
+ });
643
+ assert.deepEqual(parseCommandArgs("--path my --argument task"), {
644
+ mode: "path",
645
+ value: "my --argument task",
646
+ runtimeArgs: [],
647
+ error: undefined,
648
+ });
649
+ assert.equal(parseCommandArgs("--task reverse engineer auth --arg owner=Ada").error, "--arg is only supported with /ralph --path");
650
+ });
651
+
652
+ test("parseCommandArgs parses quoted explicit-path args and preserves literal equals", () => {
653
+ assert.deepEqual(parseCommandArgs('--path my-task --arg owner="Ada Lovelace"'), {
654
+ mode: "path",
655
+ value: "my-task",
656
+ runtimeArgs: [{ name: "owner", value: "Ada Lovelace" }],
657
+ error: undefined,
658
+ });
659
+ assert.deepEqual(parseCommandArgs("--path my-task --arg team='core infra' --arg note='a=b=c'"), {
660
+ mode: "path",
661
+ value: "my-task",
662
+ runtimeArgs: [
663
+ { name: "team", value: "core infra" },
664
+ { name: "note", value: "a=b=c" },
665
+ ],
666
+ error: undefined,
667
+ });
668
+ assert.equal(parseCommandArgs('--path my-task --arg owner="Ada Lovelace" --arg owner=\'Ada Smith\'').error, "Duplicate --arg: owner");
669
+ });
670
+
671
+ test("parseCommandArgs rejects malformed explicit-path args", () => {
672
+ assert.equal(parseCommandArgs("--path my-task --arg owner=").error, "Invalid --arg entry: value is required");
673
+ assert.equal(parseCommandArgs("--path my-task --arg =Ada").error, "Invalid --arg entry: name is required");
674
+ assert.equal(
675
+ parseCommandArgs("--path my-task --arg owner=Ada Lovelace").error,
676
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
677
+ );
678
+ assert.equal(
679
+ parseCommandArgs("--path my-task --arg owner=Ada extra text").error,
680
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
681
+ );
682
+ assert.equal(
683
+ parseCommandArgs("--path my-task --arg=name=value").error,
684
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
685
+ );
686
+ assert.equal(
687
+ parseCommandArgs("--path my-task --argowner=Ada").error,
688
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
689
+ );
690
+ assert.equal(
691
+ parseCommandArgs('--path my-task --arg owner="Ada"extra').error,
692
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
693
+ );
694
+ assert.equal(
695
+ parseCommandArgs('--path my-task --arg owner="Ada"--arg team=core').error,
696
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
697
+ );
698
+ assert.equal(
699
+ parseCommandArgs('--path my-task --arg owner=pre"Ada Lovelace"post').error,
700
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
701
+ );
702
+ assert.equal(
703
+ parseCommandArgs("--path my-task --arg owner='Ada'--arg team=core").error,
704
+ "Invalid --arg syntax: values must be a single token and no trailing text is allowed",
705
+ );
706
+ });
707
+
708
+ test("runtimeArgEntriesToMap preserves special arg names and command substitution can read them", () => {
709
+ const parsed = parseCommandArgs("--path my-task --arg __proto__=safe");
710
+ assert.equal(parsed.error, undefined);
711
+
712
+ const mapped = runtimeArgEntriesToMap(parsed.runtimeArgs);
713
+ assert.equal(mapped.error, undefined);
714
+ assert.equal(Object.getPrototypeOf(mapped.runtimeArgs), null);
715
+ assert.deepEqual(Object.keys(mapped.runtimeArgs), ["__proto__"]);
716
+ assert.equal(resolveCommandRun("echo {{ args.__proto__ }}", mapped.runtimeArgs), "echo 'safe'");
166
717
  });
167
718
 
168
719
  test("explicit path mode stays path-centric and does not offer task fallback", async () => {
@@ -245,10 +796,142 @@ test("validateDraftContent rejects missing and malformed frontmatter", () => {
245
796
  assert.equal(validateDraftContent("Task body"), "Missing RALPH frontmatter");
246
797
  assert.equal(
247
798
  validateDraftContent("---\nmax_iterations: 0\n---\nBody"),
248
- "Invalid max_iterations: must be a positive finite integer",
799
+ "Invalid max_iterations: must be between 1 and 50",
249
800
  );
250
801
  });
251
802
 
803
+ test("validateDraftContent fails closed on YAML frontmatter that is not a mapping", () => {
804
+ assert.equal(validateDraftContent("---\n- nope\n---\nBody"), "Invalid RALPH frontmatter: Frontmatter must be a YAML mapping");
805
+ });
806
+
807
+ test("inspectDraftContent and validateDraftContent fail closed on raw malformed frontmatter shapes and scalars", () => {
808
+ const makeRawDraft = (frontmatterLines: readonly string[]) => `---\n${frontmatterLines.join("\n")}\n---\nTask: Fix flaky auth tests\n\nKeep the change small.`;
809
+
810
+ for (const { label, raw, expectedError } of [
811
+ {
812
+ label: "commands mapping",
813
+ raw: makeRawDraft([
814
+ "commands:",
815
+ " name: tests",
816
+ " run: npm test",
817
+ " timeout: 20",
818
+ "max_iterations: 2",
819
+ "timeout: 300",
820
+ ]),
821
+ expectedError: "Invalid RALPH frontmatter: commands must be a YAML sequence",
822
+ },
823
+ {
824
+ label: "max_iterations boolean",
825
+ raw: makeRawDraft(["commands: []", "max_iterations: true", "timeout: 300"]),
826
+ expectedError: "Invalid RALPH frontmatter: max_iterations must be a YAML number",
827
+ },
828
+ {
829
+ label: "timeout boolean",
830
+ raw: makeRawDraft(["commands: []", "max_iterations: 2", "timeout: true"]),
831
+ expectedError: "Invalid RALPH frontmatter: timeout must be a YAML number",
832
+ },
833
+ {
834
+ label: "command name array",
835
+ raw: makeRawDraft([
836
+ "commands:",
837
+ " - name:",
838
+ " - build",
839
+ " run: npm test",
840
+ " timeout: 20",
841
+ "max_iterations: 2",
842
+ "timeout: 300",
843
+ ]),
844
+ expectedError: "Invalid RALPH frontmatter: commands[0].name must be a YAML string",
845
+ },
846
+ {
847
+ label: "command run array",
848
+ raw: makeRawDraft([
849
+ "commands:",
850
+ " - name: build",
851
+ " run:",
852
+ " - npm test",
853
+ " timeout: 20",
854
+ "max_iterations: 2",
855
+ "timeout: 300",
856
+ ]),
857
+ expectedError: "Invalid RALPH frontmatter: commands[0].run must be a YAML string",
858
+ },
859
+ {
860
+ label: "command timeout array",
861
+ raw: makeRawDraft([
862
+ "commands:",
863
+ " - name: build",
864
+ " run: npm test",
865
+ " timeout:",
866
+ " - 20",
867
+ "max_iterations: 2",
868
+ "timeout: 300",
869
+ ]),
870
+ expectedError: "Invalid RALPH frontmatter: commands[0].timeout must be a YAML number",
871
+ },
872
+ ] as const) {
873
+ assert.equal(inspectDraftContent(raw).error, expectedError, label);
874
+ assert.equal(validateDraftContent(raw), expectedError, label);
875
+ }
876
+ });
877
+
878
+ test("inspectDraftContent, validateDraftContent, and Mission Brief fail closed on raw malformed required_outputs values", () => {
879
+ const plan = generateDraft(
880
+ "Fix flaky auth tests",
881
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
882
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
883
+ );
884
+
885
+ for (const { label, rawValue, expectedError, briefError } of [
886
+ {
887
+ label: "required_outputs scalar",
888
+ rawValue: "required_outputs: docs/ARCHITECTURE.md",
889
+ expectedError: "Invalid RALPH frontmatter: required_outputs must be a YAML sequence",
890
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: required_outputs must be a YAML sequence/,
891
+ },
892
+ {
893
+ label: "required_outputs entry number",
894
+ rawValue: "required_outputs:\n - 123",
895
+ expectedError: "Invalid RALPH frontmatter: required_outputs[0] must be a YAML string",
896
+ briefError: /Invalid RALPH\.md: Invalid RALPH frontmatter: required_outputs\[0\] must be a YAML string/,
897
+ },
898
+ ] as const) {
899
+ const raw = plan.content.replace("timeout: 300\n", `${rawValue}\ntimeout: 300\n`);
900
+
901
+ assert.equal(inspectDraftContent(raw).error, expectedError, label);
902
+ assert.equal(validateDraftContent(raw), expectedError, label);
903
+
904
+ const brief = buildMissionBrief({ ...plan, content: raw });
905
+ assert.match(brief, /^Mission Brief/m, label);
906
+ assert.match(brief, briefError, label);
907
+ assert.doesNotMatch(brief, /Finish behavior/, label);
908
+ assert.doesNotMatch(brief, /<promise>/, label);
909
+ }
910
+ });
911
+
912
+
913
+ test("inspectDraftContent and validateDraftContent reject metadata-tagged generated drafts with malformed commands mappings", () => {
914
+ const raw = makeStrengthenedDraft(
915
+ [
916
+ "commands:",
917
+ " name: tests",
918
+ " run: npm test",
919
+ " timeout: 20",
920
+ "max_iterations: 20",
921
+ "timeout: 120",
922
+ "guardrails:",
923
+ " block_commands:",
924
+ " - 'git\\s+push'",
925
+ " protected_files:",
926
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
927
+ ],
928
+ "Task: Fix flaky auth tests\n\nKeep the change small.",
929
+ );
930
+
931
+ assert.equal(inspectDraftContent(raw).error, "Invalid RALPH frontmatter: commands must be a YAML sequence");
932
+ assert.equal(validateDraftContent(raw), "Invalid RALPH frontmatter: commands must be a YAML sequence");
933
+ });
934
+
252
935
  test("buildMissionBrief fails closed when the current draft content is invalid", () => {
253
936
  const plan = generateDraft(
254
937
  "Fix flaky auth tests",
@@ -334,6 +1017,8 @@ test("generated drafts reparse as valid RALPH files", () => {
334
1017
 
335
1018
  const reparsed = parseRalphMarkdown(draft.content);
336
1019
  assert.equal(validateFrontmatter(reparsed.frontmatter), null);
1020
+ assert.equal(draft.source, "deterministic");
1021
+ assertMetadataSource(extractDraftMetadata(draft.content), "deterministic");
337
1022
  assert.deepEqual(reparsed.frontmatter.commands, [
338
1023
  { name: "git-log", run: "git log --oneline -10", timeout: 20 },
339
1024
  { name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
@@ -344,8 +1029,11 @@ test("generated drafts reparse as valid RALPH files", () => {
344
1029
  { name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 },
345
1030
  ],
346
1031
  maxIterations: 12,
1032
+ interIterationDelay: 0,
347
1033
  timeout: 300,
348
1034
  completionPromise: undefined,
1035
+ requiredOutputs: [],
1036
+ stopOnError: true,
349
1037
  guardrails: { blockCommands: ["git\\s+push"], protectedFiles: [] },
350
1038
  invalidCommandEntries: undefined,
351
1039
  });
@@ -353,6 +1041,696 @@ test("generated drafts reparse as valid RALPH files", () => {
353
1041
  assert.match(reparsed.body, /\{\{ commands.git-log \}\}/);
354
1042
  assert.match(reparsed.body, /\{\{ ralph.iteration \}\}/);
355
1043
  assert.equal(extractDraftMetadata(draft.content)?.mode, "analysis");
1044
+ assertMetadataSource(extractDraftMetadata(draft.content), "deterministic");
1045
+ });
1046
+
1047
+ test("extractDraftMetadata accepts Phase 1 and Phase 2 metadata", () => {
1048
+ 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`;
1049
+ 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`;
1050
+
1051
+ assert.deepEqual(extractDraftMetadata(phase1), {
1052
+ generator: "pi-ralph-loop",
1053
+ version: 1,
1054
+ task: "Fix flaky auth tests",
1055
+ mode: "fix",
1056
+ });
1057
+ assert.deepEqual(extractDraftMetadata(phase2), {
1058
+ generator: "pi-ralph-loop",
1059
+ version: 2,
1060
+ source: "llm-strengthened",
1061
+ task: "Fix flaky auth tests",
1062
+ mode: "fix",
1063
+ });
1064
+ });
1065
+
1066
+ test("buildDraftRequest tags deterministic command intents and seeds a baseline draft", () => {
1067
+ const repoSignals: RepoSignals = { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] };
1068
+ const repoContext = buildRepoContext(repoSignals);
1069
+ const request = buildDraftRequest(
1070
+ "Fix flaky auth tests",
1071
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
1072
+ repoSignals,
1073
+ repoContext,
1074
+ );
1075
+
1076
+ assert.equal(request.mode, "fix");
1077
+ assert.deepEqual(request.repoSignals, repoSignals);
1078
+ assert.deepEqual(request.repoContext, repoContext);
1079
+ assert.deepEqual(request.repoContext.selectedFiles, [{ path: "package.json", content: "", reason: "top-level file" }]);
1080
+ assert.deepEqual(
1081
+ request.commandIntent.map(({ name, source }) => ({ name, source })),
1082
+ [
1083
+ { name: "tests", source: "repo-signal" },
1084
+ { name: "lint", source: "repo-signal" },
1085
+ { name: "git-log", source: "heuristic" },
1086
+ ],
1087
+ );
1088
+ assertMetadataSource(extractDraftMetadata(request.baselineDraft), "deterministic");
1089
+ assert.ok(request.baselineDraft.length > 0);
1090
+ });
1091
+
1092
+ test("normalizeStrengthenedDraft keeps deterministic frontmatter in body-only mode", () => {
1093
+ const request = buildDraftRequest(
1094
+ "Fix flaky auth tests",
1095
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
1096
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
1097
+ { summaryLines: ["repo summary"], selectedFiles: [{ path: "package.json", content: "", reason: "top-level file" }] },
1098
+ );
1099
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1100
+ 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.`;
1101
+
1102
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
1103
+ const reparsed = parseRalphMarkdown(normalized.content);
1104
+
1105
+ assert.deepEqual(reparsed.frontmatter.commands, baseline.frontmatter.commands);
1106
+ assert.deepEqual(reparsed.frontmatter.guardrails, baseline.frontmatter.guardrails);
1107
+ assert.equal(reparsed.body.trimStart(), "Task: Fix flaky auth tests\n\nRead-only enforced and write protection is enforced.");
1108
+ assert.deepEqual(extractDraftMetadata(normalized.content), {
1109
+ generator: "pi-ralph-loop",
1110
+ version: 2,
1111
+ source: "llm-strengthened",
1112
+ task: "Fix flaky auth tests",
1113
+ mode: "fix",
1114
+ });
1115
+ });
1116
+
1117
+ test("normalizeStrengthenedDraft keeps declared args placeholders in body-only mode", () => {
1118
+ const request = makeFixRequestWithArgs(["owner"]);
1119
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1120
+ const strengthenedDraft = makeStrengthenedDraft(
1121
+ [
1122
+ "args:",
1123
+ " - mode",
1124
+ "commands:",
1125
+ " - name: tests",
1126
+ " run: npm test",
1127
+ " timeout: 20",
1128
+ "max_iterations: 25",
1129
+ "timeout: 300",
1130
+ "guardrails:",
1131
+ " block_commands:",
1132
+ " - 'git\\s+push'",
1133
+ " protected_files:",
1134
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1135
+ ],
1136
+ "Task: Fix flaky auth tests\n\nUse {{ args.owner }} to scope the fix.",
1137
+ );
1138
+
1139
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
1140
+ const reparsed = parseRalphMarkdown(normalized.content);
1141
+
1142
+ const { args: _args, ...baselineFrontmatter } = baseline.frontmatter;
1143
+ assert.deepEqual(reparsed.frontmatter, baselineFrontmatter);
1144
+ assert.equal(reparsed.body.trimStart(), "Task: Fix flaky auth tests\n\nUse {{ args.owner }} to scope the fix.");
1145
+ });
1146
+
1147
+ test("normalizeStrengthenedDraft falls back to the baseline body when body-only args placeholders are undeclared", () => {
1148
+ const request = makeFixRequestWithArgs(["owner"]);
1149
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1150
+ const strengthenedDraft = makeStrengthenedDraft(
1151
+ [
1152
+ "args:",
1153
+ " - mode",
1154
+ "commands:",
1155
+ " - name: tests",
1156
+ " run: npm test",
1157
+ " timeout: 20",
1158
+ "max_iterations: 25",
1159
+ "timeout: 300",
1160
+ "guardrails:",
1161
+ " block_commands:",
1162
+ " - 'git\\s+push'",
1163
+ " protected_files:",
1164
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1165
+ ],
1166
+ "Task: Fix flaky auth tests\n\nUse {{ args.mode }} to scope the fix.",
1167
+ );
1168
+
1169
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
1170
+ const reparsed = parseRalphMarkdown(normalized.content);
1171
+
1172
+ const { args: _args, ...baselineFrontmatter } = baseline.frontmatter;
1173
+ assert.deepEqual(reparsed.frontmatter, baselineFrontmatter);
1174
+ assert.equal(reparsed.body.trimStart(), baseline.body.trimStart());
1175
+ });
1176
+
1177
+ test("normalizeStrengthenedDraft falls back to the baseline body when body-only frontmatter is invalid", () => {
1178
+ const request = makeFixRequest();
1179
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1180
+ const strengthenedDraft = makeStrengthenedDraft(
1181
+ [
1182
+ "commands:",
1183
+ " name: rogue",
1184
+ " run: rm -rf /",
1185
+ " timeout: 1",
1186
+ "max_iterations: 20",
1187
+ "timeout: 120",
1188
+ "guardrails:",
1189
+ " block_commands:",
1190
+ " - 'git\\s+push'",
1191
+ " protected_files:",
1192
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1193
+ ],
1194
+ "Task: Fix flaky auth tests\n\nRead-only enforced and write protection is enforced.",
1195
+ );
1196
+
1197
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
1198
+ const reparsed = parseRalphMarkdown(normalized.content);
1199
+
1200
+ assert.deepEqual(reparsed.frontmatter, baseline.frontmatter);
1201
+ assert.equal(reparsed.body.trimStart(), baseline.body.trimStart());
1202
+ });
1203
+
1204
+ test("normalizeStrengthenedDraft falls back to the baseline body when body-only YAML syntax is malformed", () => {
1205
+ const request = makeFixRequest();
1206
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1207
+ const strengthenedDraft = makeStrengthenedDraft(
1208
+ [
1209
+ "commands: [",
1210
+ "max_iterations: 20",
1211
+ "timeout: 120",
1212
+ "guardrails:",
1213
+ " block_commands:",
1214
+ " - 'git\\s+push'",
1215
+ " protected_files:",
1216
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1217
+ ],
1218
+ "Task: Fix flaky auth tests\n\nRead-only enforced and write protection is enforced.",
1219
+ );
1220
+
1221
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-only");
1222
+ const reparsed = parseRalphMarkdown(normalized.content);
1223
+
1224
+ assert.deepEqual(reparsed.frontmatter, baseline.frontmatter);
1225
+ assert.equal(reparsed.body.trimStart(), baseline.body.trimStart());
1226
+ });
1227
+
1228
+ test("normalizeStrengthenedDraft applies strengthened commands in body-and-commands mode", () => {
1229
+ const request = makeFixRequest();
1230
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1231
+ const strengthenedDraft = makeStrengthenedDraft(
1232
+ [
1233
+ "commands:",
1234
+ " - name: git-log",
1235
+ " run: git log --oneline -10",
1236
+ " timeout: 15",
1237
+ " - name: tests",
1238
+ " run: npm test",
1239
+ " timeout: 45",
1240
+ "max_iterations: 20",
1241
+ "timeout: 120",
1242
+ "guardrails:",
1243
+ " block_commands:",
1244
+ " - 'git\\s+push'",
1245
+ " protected_files:",
1246
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1247
+ ],
1248
+ "Task: Fix flaky auth tests\n\nUse {{ commands.tests }} and {{ commands.git-log }}.\n\nIteration {{ ralph.iteration }} of {{ ralph.name }}.",
1249
+ );
1250
+
1251
+ const accepted = acceptStrengthenedDraft(request, strengthenedDraft);
1252
+ if (!accepted) {
1253
+ assert.fail("expected strengthened draft to be accepted");
1254
+ }
1255
+
1256
+ const normalized = normalizeStrengthenedDraft(request, strengthenedDraft, "body-and-commands");
1257
+ assert.equal(normalized.content, accepted.content);
1258
+ const reparsed = parseRalphMarkdown(normalized.content);
1259
+
1260
+ assert.deepEqual(reparsed.frontmatter.commands, [
1261
+ { name: "git-log", run: "git log --oneline -10", timeout: 15 },
1262
+ { name: "tests", run: "npm test", timeout: 45 },
1263
+ ]);
1264
+ assert.equal(reparsed.frontmatter.maxIterations, 20);
1265
+ assert.equal(reparsed.frontmatter.timeout, 120);
1266
+ assert.deepEqual(reparsed.frontmatter.guardrails, baseline.frontmatter.guardrails);
1267
+ assert.equal(validateFrontmatter(reparsed.frontmatter), null);
1268
+ assert.match(reparsed.body, /Use \{\{ commands\.tests \}\} and \{\{ commands\.git-log \}\}\./);
1269
+ });
1270
+
1271
+ test("acceptStrengthenedDraft rejects malformed commands frontmatter shapes", () => {
1272
+ const request = makeFixRequest();
1273
+ const body = "Task: Fix flaky auth tests\n\nKeep the change small.";
1274
+
1275
+ assert.equal(
1276
+ acceptStrengthenedDraft(
1277
+ request,
1278
+ makeStrengthenedDraft(
1279
+ [
1280
+ "commands:",
1281
+ " name: tests",
1282
+ " run: npm test",
1283
+ " timeout: 20",
1284
+ "max_iterations: 20",
1285
+ "timeout: 120",
1286
+ "guardrails:",
1287
+ " block_commands:",
1288
+ " - 'git\\s+push'",
1289
+ " protected_files:",
1290
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1291
+ ],
1292
+ body,
1293
+ ),
1294
+ ),
1295
+ null,
1296
+ );
1297
+ });
1298
+
1299
+ test("acceptStrengthenedDraft rejects invented, renamed, swapped, and duplicate commands", () => {
1300
+ const request = makeFixRequest();
1301
+ const body = "Task: Fix flaky auth tests\n\nKeep the change small.";
1302
+
1303
+ assert.equal(
1304
+ acceptStrengthenedDraft(
1305
+ request,
1306
+ makeStrengthenedDraft(
1307
+ [
1308
+ "commands:",
1309
+ " - name: smoke",
1310
+ " run: npm run smoke",
1311
+ " timeout: 20",
1312
+ "max_iterations: 20",
1313
+ "timeout: 120",
1314
+ "guardrails:",
1315
+ " block_commands:",
1316
+ " - 'git\\s+push'",
1317
+ " protected_files:",
1318
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1319
+ ],
1320
+ body,
1321
+ ),
1322
+ ),
1323
+ null,
1324
+ );
1325
+
1326
+ assert.equal(
1327
+ acceptStrengthenedDraft(
1328
+ request,
1329
+ makeStrengthenedDraft(
1330
+ [
1331
+ "commands:",
1332
+ " - name: unit-tests",
1333
+ " run: npm test",
1334
+ " timeout: 20",
1335
+ "max_iterations: 20",
1336
+ "timeout: 120",
1337
+ "guardrails:",
1338
+ " block_commands:",
1339
+ " - 'git\\s+push'",
1340
+ " protected_files:",
1341
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1342
+ ],
1343
+ body,
1344
+ ),
1345
+ ),
1346
+ null,
1347
+ );
1348
+
1349
+ assert.equal(
1350
+ acceptStrengthenedDraft(
1351
+ request,
1352
+ makeStrengthenedDraft(
1353
+ [
1354
+ "commands:",
1355
+ " - name: tests",
1356
+ " run: git log --oneline -10",
1357
+ " timeout: 20",
1358
+ " - name: git-log",
1359
+ " run: npm test",
1360
+ " timeout: 20",
1361
+ "max_iterations: 20",
1362
+ "timeout: 120",
1363
+ "guardrails:",
1364
+ " block_commands:",
1365
+ " - 'git\\s+push'",
1366
+ " protected_files:",
1367
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1368
+ ],
1369
+ body,
1370
+ ),
1371
+ ),
1372
+ null,
1373
+ );
1374
+
1375
+ assert.equal(
1376
+ acceptStrengthenedDraft(
1377
+ request,
1378
+ makeStrengthenedDraft(
1379
+ [
1380
+ "commands:",
1381
+ " - name: tests",
1382
+ " run: npm test",
1383
+ " timeout: 20",
1384
+ " - name: tests",
1385
+ " run: npm test",
1386
+ " timeout: 20",
1387
+ "max_iterations: 20",
1388
+ "timeout: 120",
1389
+ "guardrails:",
1390
+ " block_commands:",
1391
+ " - 'git\\s+push'",
1392
+ " protected_files:",
1393
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1394
+ ],
1395
+ body,
1396
+ ),
1397
+ ),
1398
+ null,
1399
+ );
1400
+ });
1401
+
1402
+ test("acceptStrengthenedDraft rejects placeholder drift, increased limits, and command-timeout overflow", () => {
1403
+ const request = makeFixRequest();
1404
+
1405
+ assert.equal(
1406
+ acceptStrengthenedDraft(
1407
+ request,
1408
+ makeStrengthenedDraft(
1409
+ [
1410
+ "commands:",
1411
+ " - name: git-log",
1412
+ " run: git log --oneline -10",
1413
+ " timeout: 20",
1414
+ " - name: tests",
1415
+ " run: npm test",
1416
+ " timeout: 20",
1417
+ "max_iterations: 26",
1418
+ "timeout: 120",
1419
+ "guardrails:",
1420
+ " block_commands:",
1421
+ " - 'git\\s+push'",
1422
+ " protected_files:",
1423
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1424
+ ],
1425
+ "Task: Fix flaky auth tests\n\nUse {{ commands.tests }} and {{ commands.lint }}.",
1426
+ ),
1427
+ ),
1428
+ null,
1429
+ );
1430
+
1431
+ assert.equal(
1432
+ acceptStrengthenedDraft(
1433
+ request,
1434
+ makeStrengthenedDraft(
1435
+ [
1436
+ "commands:",
1437
+ " - name: git-log",
1438
+ " run: git log --oneline -10",
1439
+ " timeout: 20",
1440
+ " - name: tests",
1441
+ " run: npm test",
1442
+ " timeout: 21",
1443
+ "max_iterations: 20",
1444
+ "timeout: 20",
1445
+ "guardrails:",
1446
+ " block_commands:",
1447
+ " - 'git\\s+push'",
1448
+ " protected_files:",
1449
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1450
+ ],
1451
+ "Task: Fix flaky auth tests\n\nUse {{ commands.tests }} and {{ commands.git-log }}.",
1452
+ ),
1453
+ ),
1454
+ null,
1455
+ );
1456
+
1457
+ assert.equal(
1458
+ acceptStrengthenedDraft(
1459
+ request,
1460
+ makeStrengthenedDraft(
1461
+ [
1462
+ "commands:",
1463
+ " - name: git-log",
1464
+ " run: git log --oneline -10",
1465
+ " timeout: 20",
1466
+ " - name: tests",
1467
+ " run: npm test",
1468
+ " timeout: 20",
1469
+ "max_iterations: 20",
1470
+ "timeout: 301",
1471
+ "guardrails:",
1472
+ " block_commands:",
1473
+ " - 'git\\s+push'",
1474
+ " protected_files:",
1475
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1476
+ ],
1477
+ "Task: Fix flaky auth tests\n\nUse {{ commands.tests }} and {{ commands.git-log }}.",
1478
+ ),
1479
+ ),
1480
+ null,
1481
+ );
1482
+ });
1483
+
1484
+ test("acceptStrengthenedDraft rejects args placeholders in strengthened bodies", () => {
1485
+ const request = makeFixRequest();
1486
+
1487
+ assert.equal(
1488
+ acceptStrengthenedDraft(
1489
+ request,
1490
+ makeStrengthenedDraft(
1491
+ [
1492
+ "args:",
1493
+ " - owner",
1494
+ "commands:",
1495
+ " - name: git-log",
1496
+ " run: git log --oneline -10",
1497
+ " timeout: 20",
1498
+ " - name: tests",
1499
+ " run: npm test",
1500
+ " timeout: 20",
1501
+ "max_iterations: 20",
1502
+ "timeout: 120",
1503
+ "guardrails:",
1504
+ " block_commands:",
1505
+ " - 'git\\s+push'",
1506
+ " protected_files:",
1507
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1508
+ ],
1509
+ "Task: Fix flaky auth tests\n\nUse {{ args.owner }} and {{ commands.tests }}.",
1510
+ ),
1511
+ ),
1512
+ null,
1513
+ );
1514
+ });
1515
+
1516
+ test("acceptStrengthenedDraft rejects args frontmatter drift", () => {
1517
+ const request = makeFixRequestWithArgs(["owner"]);
1518
+ const body = "Task: Fix flaky auth tests\n\nKeep the change small.";
1519
+
1520
+ assert.equal(
1521
+ acceptStrengthenedDraft(
1522
+ request,
1523
+ makeStrengthenedDraft(
1524
+ [
1525
+ "args:",
1526
+ " - owner",
1527
+ " - mode",
1528
+ "commands:",
1529
+ " - name: git-log",
1530
+ " run: git log --oneline -10",
1531
+ " timeout: 20",
1532
+ " - name: tests",
1533
+ " run: npm test",
1534
+ " timeout: 20",
1535
+ "max_iterations: 20",
1536
+ "timeout: 120",
1537
+ "guardrails:",
1538
+ " block_commands:",
1539
+ " - 'git\\s+push'",
1540
+ " protected_files:",
1541
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1542
+ ],
1543
+ body,
1544
+ ),
1545
+ ),
1546
+ null,
1547
+ );
1548
+ });
1549
+
1550
+ test("acceptStrengthenedDraft rejects changed guardrails and missing secret-path protection", () => {
1551
+ const request = makeFixRequest();
1552
+ const body = "Task: Fix flaky auth tests\n\nKeep the change small.";
1553
+
1554
+ assert.equal(
1555
+ acceptStrengthenedDraft(
1556
+ request,
1557
+ makeStrengthenedDraft(
1558
+ [
1559
+ "commands:",
1560
+ " - name: git-log",
1561
+ " run: git log --oneline -10",
1562
+ " timeout: 20",
1563
+ " - name: tests",
1564
+ " run: npm test",
1565
+ " timeout: 20",
1566
+ "max_iterations: 20",
1567
+ "timeout: 120",
1568
+ "guardrails:",
1569
+ " block_commands:",
1570
+ " - 'git\\s+push'",
1571
+ " protected_files:",
1572
+ " - .env*",
1573
+ ],
1574
+ body,
1575
+ ),
1576
+ ),
1577
+ null,
1578
+ );
1579
+
1580
+ assert.equal(
1581
+ acceptStrengthenedDraft(
1582
+ request,
1583
+ makeStrengthenedDraft(
1584
+ [
1585
+ "commands:",
1586
+ " - name: git-log",
1587
+ " run: git log --oneline -10",
1588
+ " timeout: 20",
1589
+ " - name: tests",
1590
+ " run: npm test",
1591
+ " timeout: 20",
1592
+ "max_iterations: 20",
1593
+ "timeout: 120",
1594
+ "guardrails:",
1595
+ " block_commands:",
1596
+ " - 'git\\s+push'",
1597
+ " protected_files: [],",
1598
+ ],
1599
+ body,
1600
+ ),
1601
+ ),
1602
+ null,
1603
+ );
1604
+ });
1605
+
1606
+ test("acceptStrengthenedDraft accepts unchanged completion_promise and rejects new or invalid ones", () => {
1607
+ const request = makeFixRequest();
1608
+ const body = "Task: Fix flaky auth tests\n\nKeep the change small.";
1609
+
1610
+ assert.equal(
1611
+ acceptStrengthenedDraft(
1612
+ request,
1613
+ makeStrengthenedDraft(
1614
+ [
1615
+ "commands:",
1616
+ " - name: git-log",
1617
+ " run: git log --oneline -10",
1618
+ " timeout: 20",
1619
+ " - name: tests",
1620
+ " run: npm test",
1621
+ " timeout: 20",
1622
+ "max_iterations: 20",
1623
+ "timeout: 120",
1624
+ "guardrails:",
1625
+ " block_commands:",
1626
+ " - 'git\\s+push'",
1627
+ " protected_files:",
1628
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1629
+ "completion_promise: ship-it",
1630
+ ],
1631
+ body,
1632
+ ),
1633
+ ),
1634
+ null,
1635
+ );
1636
+
1637
+ const promisedRequest = makeFixRequestWithCompletionPromise("ship-it");
1638
+ const promisedDraft = makeStrengthenedDraft(
1639
+ [
1640
+ "commands:",
1641
+ " - name: git-log",
1642
+ " run: git log --oneline -10",
1643
+ " timeout: 20",
1644
+ " - name: tests",
1645
+ " run: npm test",
1646
+ " timeout: 20",
1647
+ "max_iterations: 20",
1648
+ "timeout: 120",
1649
+ "guardrails:",
1650
+ " block_commands:",
1651
+ " - 'git\\s+push'",
1652
+ " protected_files:",
1653
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1654
+ "completion_promise: ship-it",
1655
+ ],
1656
+ body,
1657
+ );
1658
+ const accepted = acceptStrengthenedDraft(promisedRequest, promisedDraft);
1659
+ if (!accepted) {
1660
+ assert.fail("expected unchanged completion_promise to be accepted");
1661
+ }
1662
+ assert.equal(parseRalphMarkdown(accepted.content).frontmatter.completionPromise, "ship-it");
1663
+
1664
+ assert.equal(
1665
+ acceptStrengthenedDraft(
1666
+ promisedRequest,
1667
+ makeStrengthenedDraft(
1668
+ [
1669
+ "commands:",
1670
+ " - name: git-log",
1671
+ " run: git log --oneline -10",
1672
+ " timeout: 20",
1673
+ " - name: tests",
1674
+ " run: npm test",
1675
+ " timeout: 20",
1676
+ "max_iterations: 20",
1677
+ "timeout: 120",
1678
+ "guardrails:",
1679
+ " block_commands:",
1680
+ " - 'git\\s+push'",
1681
+ " protected_files:",
1682
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1683
+ "completion_promise: ship-it-too",
1684
+ ],
1685
+ body,
1686
+ ),
1687
+ ),
1688
+ null,
1689
+ );
1690
+
1691
+ assert.equal(
1692
+ acceptStrengthenedDraft(
1693
+ request,
1694
+ makeStrengthenedDraft(
1695
+ [
1696
+ "commands:",
1697
+ " - name: git-log",
1698
+ " run: git log --oneline -10",
1699
+ " timeout: 20",
1700
+ " - name: tests",
1701
+ " run: npm test",
1702
+ " timeout: 20",
1703
+ "max_iterations: 20",
1704
+ "timeout: 120",
1705
+ "guardrails:",
1706
+ " block_commands:",
1707
+ " - 'git\\s+push'",
1708
+ " protected_files:",
1709
+ ` - '${SECRET_PATH_POLICY_TOKEN}'`,
1710
+ "completion_promise: [oops]",
1711
+ ],
1712
+ body,
1713
+ ),
1714
+ ),
1715
+ null,
1716
+ );
1717
+ });
1718
+
1719
+ test("isWeakStrengthenedDraft rejects unchanged bodies and fake runtime enforcement claims", () => {
1720
+ const request = buildDraftRequest(
1721
+ "Fix flaky auth tests",
1722
+ { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
1723
+ { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
1724
+ { summaryLines: ["repo summary"], selectedFiles: [{ path: "package.json", content: "", reason: "top-level file" }] },
1725
+ );
1726
+ const baselineBody = parseRalphMarkdown(request.baselineDraft).body;
1727
+ const unchangedBody = baselineBody;
1728
+ const changedBody = `${baselineBody}\n\nAdd concrete verification steps.`;
1729
+
1730
+ assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", unchangedBody), true);
1731
+ assert.equal(isWeakStrengthenedDraft(baselineBody, "read-only enforced", changedBody), true);
1732
+ assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", `write protection is enforced\n\n${changedBody}`), true);
1733
+ assert.equal(isWeakStrengthenedDraft(baselineBody, "analysis text", changedBody), false);
356
1734
  });
357
1735
 
358
1736
  test("generated draft starts fail closed when validation no longer passes", async () => {
@@ -393,7 +1771,7 @@ test("generated draft starts fail closed when validation no longer passes", asyn
393
1771
 
394
1772
  await handler(`--path ${ralphPath}`, ctx);
395
1773
 
396
- assert.deepEqual(notifications, [{ level: "error", message: "Invalid RALPH.md: Invalid max_iterations: must be a positive finite integer" }]);
1774
+ assert.deepEqual(notifications, [{ level: "error", message: "Invalid RALPH.md: Invalid max_iterations: must be between 1 and 50" }]);
397
1775
  });
398
1776
 
399
1777
  test("generateDraft creates metadata-rich analysis and fix drafts", () => {
@@ -402,26 +1780,37 @@ test("generateDraft creates metadata-rich analysis and fix drafts", () => {
402
1780
  { slug: "reverse-engineer-this-app", dirPath: "/repo/reverse-engineer-this-app", ralphPath: "/repo/reverse-engineer-this-app/RALPH.md" },
403
1781
  { packageManager: "npm", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
404
1782
  );
1783
+ const analysisParsed = parseRalphMarkdown(analysisDraft.content);
405
1784
  assert.equal(analysisDraft.mode, "analysis");
1785
+ assert.equal(analysisDraft.source, "deterministic");
406
1786
  assert.equal(extractDraftMetadata(analysisDraft.content)?.mode, "analysis");
1787
+ assertMetadataSource(extractDraftMetadata(analysisDraft.content), "deterministic");
407
1788
  assert.match(analysisDraft.content, /Start with read-only inspection/);
408
1789
  assert.match(analysisDraft.content, /\{\{ commands.repo-map \}\}/);
409
1790
  assert.equal(analysisDraft.safetyLabel, "blocks git push");
1791
+ assert.deepEqual(analysisParsed.frontmatter.guardrails.protectedFiles, []);
410
1792
  assert.doesNotMatch(analysisDraft.content, /\*\*\/\*/);
411
1793
  const analysisBrief = buildMissionBrief(analysisDraft);
412
1794
  assert.match(analysisBrief, /- blocks git push/);
413
1795
  assert.doesNotMatch(analysisBrief, /read-only/);
1796
+ assert.doesNotMatch(analysisBrief, /Required outputs must exist before stopping/);
1797
+ assert.doesNotMatch(analysisBrief, /OPEN_QUESTIONS\.md must have no remaining P0\/P1 items before stopping\./);
414
1798
 
415
1799
  const fixDraft = generateDraft(
416
1800
  "Fix flaky auth tests",
417
1801
  { slug: "fix-flaky-auth-tests", dirPath: "/repo/fix-flaky-auth-tests", ralphPath: "/repo/fix-flaky-auth-tests/RALPH.md" },
418
1802
  { packageManager: "npm", testCommand: "npm test", lintCommand: "npm run lint", hasGit: true, topLevelDirs: ["src"], topLevelFiles: ["package.json"] },
419
1803
  );
1804
+ const fixParsed = parseRalphMarkdown(fixDraft.content);
420
1805
  assert.equal(fixDraft.mode, "fix");
1806
+ assert.equal(fixDraft.source, "deterministic");
421
1807
  assert.match(fixDraft.content, /If tests or lint are failing/);
422
1808
  assert.match(fixDraft.content, /\{\{ commands.tests \}\}/);
423
1809
  assert.match(fixDraft.content, /\{\{ commands.lint \}\}/);
424
1810
  assert.equal(extractDraftMetadata(fixDraft.content)?.task, "Fix flaky auth tests");
1811
+ assertMetadataSource(extractDraftMetadata(fixDraft.content), "deterministic");
1812
+ assert.deepEqual(fixParsed.frontmatter.guardrails.protectedFiles, [SECRET_PATH_POLICY_TOKEN]);
1813
+ assert.match(fixDraft.safetyLabel, /secret files/);
425
1814
  });
426
1815
 
427
1816
  test("generated draft metadata survives task text containing HTML comment markers", () => {
@@ -441,7 +1830,7 @@ test("generated draft metadata survives task text containing HTML comment marker
441
1830
  assert.equal(validateDraftContent(draft.content), null);
442
1831
  assert.match(draft.content, /Task: Reverse engineer the parser &lt;!-- tricky --&gt; and document the edge case/);
443
1832
  assert.match(parsed.body, /Task: Reverse engineer the parser &lt;!-- tricky --&gt; and document the edge case/);
444
- const rendered = renderRalphBody(parsed.body, [], { iteration: 1, name: "ralph" });
1833
+ const rendered = renderRalphBody(parsed.body, [], { iteration: 1, name: "ralph", maxIterations: 1 });
445
1834
  assert.match(rendered, /Task: Reverse engineer the parser &lt;!-- tricky --&gt; and document the edge case/);
446
1835
  assert.doesNotMatch(rendered, /<!-- tricky -->/);
447
1836
  });
@@ -457,7 +1846,8 @@ test("buildMissionBrief refreshes after draft edits", () => {
457
1846
  content: plan.content
458
1847
  .replace("Task: Fix flaky auth tests", "Task: Fix flaky auth regressions")
459
1848
  .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"),
1849
+ .replace("max_iterations: 25", "max_iterations: 7")
1850
+ .replace("timeout: 300\n", "timeout: 90\ncompletion_promise: deploy-ready\n"),
461
1851
  };
462
1852
 
463
1853
  const brief = buildMissionBrief(editedPlan);
@@ -466,5 +1856,9 @@ test("buildMissionBrief refreshes after draft edits", () => {
466
1856
  assert.doesNotMatch(brief, /Fix flaky auth tests/);
467
1857
  assert.match(brief, /smoke: npm run smoke/);
468
1858
  assert.match(brief, /Stop after 7 iterations or \/ralph-stop/);
1859
+ assert.match(brief, /Stop if an iteration exceeds 90s/);
1860
+ assert.match(brief, /OPEN_QUESTIONS\.md must have no remaining P0\/P1 items before stopping\./);
1861
+ assert.match(brief, /Stop early on <promise>deploy-ready<\/promise>/);
1862
+ assert.match(brief, /OPEN_QUESTIONS\.md must have no remaining P0\/P1 items before stopping\./);
469
1863
  assert.doesNotMatch(brief, /tests: npm test/);
470
1864
  });