@lnilluv/pi-ralph-loop 0.1.4-dev.0 → 0.1.4-dev.1

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 (38) hide show
  1. package/README.md +63 -12
  2. package/package.json +1 -1
  3. package/src/index.ts +1034 -168
  4. package/src/ralph-draft-llm.ts +35 -7
  5. package/src/ralph-draft.ts +1 -1
  6. package/src/ralph.ts +708 -51
  7. package/src/runner-rpc.ts +434 -0
  8. package/src/runner-state.ts +822 -0
  9. package/src/runner.ts +957 -0
  10. package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
  11. package/tests/fixtures/parity/migrate/RALPH.md +27 -0
  12. package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
  13. package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
  14. package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
  15. package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
  16. package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
  17. package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
  18. package/tests/fixtures/parity/research/RALPH.md +45 -0
  19. package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
  20. package/tests/fixtures/parity/research/expected-outputs.md +22 -0
  21. package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
  22. package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
  23. package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
  24. package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
  25. package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
  26. package/tests/fixtures/parity/research/source-manifest.md +20 -0
  27. package/tests/index.test.ts +3169 -104
  28. package/tests/parity/README.md +9 -0
  29. package/tests/parity/harness.py +526 -0
  30. package/tests/parity-harness.test.ts +42 -0
  31. package/tests/parity-research-fixture.test.ts +34 -0
  32. package/tests/ralph-draft-llm.test.ts +82 -9
  33. package/tests/ralph-draft.test.ts +1 -1
  34. package/tests/ralph.test.ts +1265 -36
  35. package/tests/runner-event-contract.test.ts +235 -0
  36. package/tests/runner-rpc.test.ts +358 -0
  37. package/tests/runner-state.test.ts +553 -0
  38. package/tests/runner.test.ts +1347 -0
package/src/ralph.ts CHANGED
@@ -7,13 +7,19 @@ export type CommandDef = { name: string; run: string; timeout: number };
7
7
  export type DraftSource = "deterministic" | "llm-strengthened" | "fallback";
8
8
  export type DraftStrengtheningScope = "body-only" | "body-and-commands";
9
9
  export type CommandIntent = CommandDef & { source: "heuristic" | "repo-signal" };
10
+ export type RuntimeArg = { name: string; value: string };
11
+ export type RuntimeArgs = Record<string, string>;
10
12
  export type Frontmatter = {
11
13
  commands: CommandDef[];
14
+ args?: string[];
12
15
  maxIterations: number;
16
+ interIterationDelay: number;
13
17
  timeout: number;
14
18
  completionPromise?: string;
19
+ requiredOutputs?: string[];
15
20
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
16
21
  invalidCommandEntries?: number[];
22
+ invalidArgEntries?: number[];
17
23
  };
18
24
  export type ParsedRalph = { frontmatter: Frontmatter; body: string };
19
25
  export type CommandOutput = { name: string; output: string };
@@ -22,9 +28,12 @@ export type RalphTargetResolution = {
22
28
  absoluteTarget: string;
23
29
  markdownPath: string;
24
30
  };
25
- export type CommandArgs =
26
- | { mode: "path" | "task"; value: string }
27
- | { mode: "auto"; value: string };
31
+ export type CommandArgs = {
32
+ mode: "path" | "task" | "auto";
33
+ value: string;
34
+ runtimeArgs: RuntimeArg[];
35
+ error?: string;
36
+ };
28
37
  export type ExistingTargetInspection =
29
38
  | { kind: "run"; ralphPath: string }
30
39
  | { kind: "invalid-markdown"; path: string }
@@ -131,6 +140,28 @@ function toStringArray(value: unknown): string[] {
131
140
  return Array.isArray(value) ? value.map((item) => String(item)) : [];
132
141
  }
133
142
 
143
+ function parseStringArray(value: unknown): { values: string[]; invalidEntries?: number[] } {
144
+ if (!Array.isArray(value)) return { values: [] };
145
+
146
+ const invalidEntries: number[] = [];
147
+ const values = value.flatMap((item, index) => {
148
+ if (typeof item !== "string") {
149
+ invalidEntries.push(index);
150
+ return [];
151
+ }
152
+ return [item];
153
+ });
154
+
155
+ return { values, invalidEntries: invalidEntries.length > 0 ? invalidEntries : undefined };
156
+ }
157
+
158
+ function isUniversalProtectedGlob(pattern: string): boolean {
159
+ const trimmed = pattern.trim().replace(/\/+$/, "");
160
+ if (!trimmed) return true;
161
+ if (/^\*+$/.test(trimmed)) return true;
162
+ return /^(?:\*\*?\/)+\*\*?$/.test(trimmed);
163
+ }
164
+
134
165
  function normalizeRawRalph(raw: string): string {
135
166
  return raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
136
167
  }
@@ -139,8 +170,159 @@ function matchRalphMarkdown(raw: string): RegExpMatchArray | null {
139
170
  return normalizeRawRalph(raw).match(/^(?:\s*<!--[\s\S]*?-->\s*)*---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
140
171
  }
141
172
 
142
- function hasRalphFrontmatter(raw: string): boolean {
143
- return matchRalphMarkdown(raw) !== null;
173
+
174
+ function validateRawGuardrailsShape(rawFrontmatter: UnknownRecord): string | null {
175
+ if (!Object.prototype.hasOwnProperty.call(rawFrontmatter, "guardrails")) {
176
+ return null;
177
+ }
178
+
179
+ const guardrails = rawFrontmatter.guardrails;
180
+ if (!isRecord(guardrails)) {
181
+ return "Invalid RALPH frontmatter: guardrails must be a YAML mapping";
182
+ }
183
+ if (
184
+ Object.prototype.hasOwnProperty.call(guardrails, "block_commands") &&
185
+ !Array.isArray(guardrails.block_commands)
186
+ ) {
187
+ return "Invalid RALPH frontmatter: guardrails.block_commands must be a YAML sequence";
188
+ }
189
+ if (
190
+ Object.prototype.hasOwnProperty.call(guardrails, "protected_files") &&
191
+ !Array.isArray(guardrails.protected_files)
192
+ ) {
193
+ return "Invalid RALPH frontmatter: guardrails.protected_files must be a YAML sequence";
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function validateRawRequiredOutputsShape(rawFrontmatter: UnknownRecord): string | null {
199
+ if (!Object.prototype.hasOwnProperty.call(rawFrontmatter, "required_outputs")) {
200
+ return null;
201
+ }
202
+
203
+ const requiredOutputs = rawFrontmatter.required_outputs;
204
+ if (!Array.isArray(requiredOutputs)) {
205
+ return "Invalid RALPH frontmatter: required_outputs must be a YAML sequence";
206
+ }
207
+ for (const [index, output] of requiredOutputs.entries()) {
208
+ if (typeof output !== "string") {
209
+ return `Invalid RALPH frontmatter: required_outputs[${index}] must be a YAML string`;
210
+ }
211
+ }
212
+ return null;
213
+ }
214
+
215
+ function validateRawArgsShape(rawFrontmatter: UnknownRecord): string | null {
216
+ if (!Object.prototype.hasOwnProperty.call(rawFrontmatter, "args")) {
217
+ return null;
218
+ }
219
+
220
+ const args = rawFrontmatter.args;
221
+ if (!Array.isArray(args)) {
222
+ return "Invalid RALPH frontmatter: args must be a YAML sequence";
223
+ }
224
+ for (const [index, arg] of args.entries()) {
225
+ if (typeof arg !== "string") {
226
+ return `Invalid RALPH frontmatter: args[${index}] must be a YAML string`;
227
+ }
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function validateRawCommandEntryShape(command: unknown, index: number): string | null {
233
+ if (!isRecord(command)) {
234
+ return `Invalid RALPH frontmatter: commands[${index}] must be a YAML mapping`;
235
+ }
236
+ if (Object.prototype.hasOwnProperty.call(command, "name") && typeof command.name !== "string") {
237
+ return `Invalid RALPH frontmatter: commands[${index}].name must be a YAML string`;
238
+ }
239
+ if (Object.prototype.hasOwnProperty.call(command, "run") && typeof command.run !== "string") {
240
+ return `Invalid RALPH frontmatter: commands[${index}].run must be a YAML string`;
241
+ }
242
+ if (Object.prototype.hasOwnProperty.call(command, "timeout") && typeof command.timeout !== "number") {
243
+ return `Invalid RALPH frontmatter: commands[${index}].timeout must be a YAML number`;
244
+ }
245
+ return null;
246
+ }
247
+
248
+ function validateRawFrontmatterShape(rawFrontmatter: UnknownRecord): string | null {
249
+ if (Object.prototype.hasOwnProperty.call(rawFrontmatter, "commands")) {
250
+ const commands = rawFrontmatter.commands;
251
+ if (!Array.isArray(commands)) {
252
+ return "Invalid RALPH frontmatter: commands must be a YAML sequence";
253
+ }
254
+ for (const [index, command] of commands.entries()) {
255
+ const commandError = validateRawCommandEntryShape(command, index);
256
+ if (commandError) return commandError;
257
+ }
258
+ }
259
+
260
+ if (
261
+ Object.prototype.hasOwnProperty.call(rawFrontmatter, "required_outputs")
262
+ ) {
263
+ const requiredOutputsError = validateRawRequiredOutputsShape(rawFrontmatter);
264
+ if (requiredOutputsError) {
265
+ return requiredOutputsError;
266
+ }
267
+ }
268
+
269
+ if (Object.prototype.hasOwnProperty.call(rawFrontmatter, "args")) {
270
+ const argsError = validateRawArgsShape(rawFrontmatter);
271
+ if (argsError) {
272
+ return argsError;
273
+ }
274
+ }
275
+
276
+ if (
277
+ Object.prototype.hasOwnProperty.call(rawFrontmatter, "max_iterations") &&
278
+ (typeof rawFrontmatter.max_iterations !== "number" || !Number.isFinite(rawFrontmatter.max_iterations))
279
+ ) {
280
+ return "Invalid RALPH frontmatter: max_iterations must be a YAML number";
281
+ }
282
+ if (
283
+ Object.prototype.hasOwnProperty.call(rawFrontmatter, "inter_iteration_delay") &&
284
+ (typeof rawFrontmatter.inter_iteration_delay !== "number" || !Number.isFinite(rawFrontmatter.inter_iteration_delay))
285
+ ) {
286
+ return "Invalid RALPH frontmatter: inter_iteration_delay must be a YAML number";
287
+ }
288
+ if (
289
+ Object.prototype.hasOwnProperty.call(rawFrontmatter, "timeout") &&
290
+ (typeof rawFrontmatter.timeout !== "number" || !Number.isFinite(rawFrontmatter.timeout))
291
+ ) {
292
+ return "Invalid RALPH frontmatter: timeout must be a YAML number";
293
+ }
294
+
295
+ return null;
296
+ }
297
+
298
+ function parseStrictRalphMarkdown(raw: string): { parsed: ParsedRalph; rawFrontmatter: UnknownRecord } | { error: string } {
299
+ const normalized = normalizeRawRalph(raw);
300
+ const match = matchRalphMarkdown(normalized);
301
+ if (!match) return { error: "Missing RALPH frontmatter" };
302
+
303
+ let parsedYaml: unknown;
304
+ try {
305
+ parsedYaml = parseYaml(match[1]);
306
+ } catch (error) {
307
+ const message = error instanceof Error ? error.message : String(error);
308
+ return { error: `Invalid RALPH frontmatter: ${message}` };
309
+ }
310
+
311
+ if (!isRecord(parsedYaml)) {
312
+ return { error: "Invalid RALPH frontmatter: Frontmatter must be a YAML mapping" };
313
+ }
314
+
315
+ const guardrailsError = validateRawGuardrailsShape(parsedYaml);
316
+ if (guardrailsError) {
317
+ return { error: guardrailsError };
318
+ }
319
+
320
+ const rawShapeError = validateRawFrontmatterShape(parsedYaml);
321
+ if (rawShapeError) {
322
+ return { error: rawShapeError };
323
+ }
324
+
325
+ return { parsed: parseRalphMarkdown(normalized), rawFrontmatter: parsedYaml };
144
326
  }
145
327
 
146
328
  function normalizeMissingMarkdownTarget(absoluteTarget: string): { dirPath: string; ralphPath: string } {
@@ -167,8 +349,55 @@ function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
167
349
  return labels.length > 0 ? labels.join(" and ") : "No extra safety rules";
168
350
  }
169
351
 
170
- function summarizeFinishLabel(maxIterations: number): string {
171
- return `Stop after ${maxIterations} iterations or /ralph-stop`;
352
+ function summarizeFinishLabel(frontmatter: Frontmatter): string {
353
+ const requiredOutputs = frontmatter.requiredOutputs ?? [];
354
+ const labels = [`Stop after ${frontmatter.maxIterations} iterations or /ralph-stop`];
355
+ if (requiredOutputs.length > 0) {
356
+ labels.push(`required outputs: ${requiredOutputs.join(", ")}`);
357
+ }
358
+ return labels.join("; ");
359
+ }
360
+
361
+ function summarizeFinishBehavior(frontmatter: Frontmatter): string[] {
362
+ const requiredOutputs = frontmatter.requiredOutputs ?? [];
363
+ const lines = [
364
+ `- Stop after ${frontmatter.maxIterations} iterations or /ralph-stop`,
365
+ `- Stop if an iteration exceeds ${frontmatter.timeout}s`,
366
+ ];
367
+
368
+ if (frontmatter.completionPromise) {
369
+ if (requiredOutputs.length > 0) {
370
+ lines.push(`- Required outputs must exist before stopping: ${requiredOutputs.join(", ")}`);
371
+ }
372
+ lines.push("- OPEN_QUESTIONS.md must have no remaining P0/P1 items before stopping.");
373
+ lines.push(`- Stop early on <promise>${frontmatter.completionPromise}</promise>`);
374
+ }
375
+
376
+ return lines;
377
+ }
378
+
379
+ function isSafeCompletionPromise(value: string): boolean {
380
+ return !/[\r\n<>]/.test(value);
381
+ }
382
+
383
+ function validateRequiredOutputEntry(value: string): string | null {
384
+ const trimmed = value.trim();
385
+ if (
386
+ !trimmed ||
387
+ trimmed !== value ||
388
+ /[\u0000-\u001f\u007f]/.test(value) ||
389
+ trimmed === "." ||
390
+ trimmed === ".." ||
391
+ trimmed.startsWith("/") ||
392
+ /^[A-Za-z]:[\\/]/.test(trimmed) ||
393
+ trimmed.includes("\\") ||
394
+ trimmed.endsWith("/") ||
395
+ trimmed.endsWith("\\") ||
396
+ trimmed.split("/").some((segment) => segment === "." || segment === "..")
397
+ ) {
398
+ return `Invalid required_outputs entry: ${value} must be a relative file path`;
399
+ }
400
+ return null;
172
401
  }
173
402
 
174
403
  function isRalphMarkdownPath(path: string): boolean {
@@ -254,7 +483,7 @@ function escapeHtmlCommentMarkers(text: string): string {
254
483
  }
255
484
 
256
485
  export function defaultFrontmatter(): Frontmatter {
257
- return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
486
+ return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], guardrails: { blockCommands: [], protectedFiles: [] } };
258
487
  }
259
488
 
260
489
  export function parseRalphMarkdown(raw: string): ParsedRalph {
@@ -272,20 +501,25 @@ export function parseRalphMarkdown(raw: string): ParsedRalph {
272
501
  }
273
502
  return [parsed];
274
503
  });
504
+ const parsedArgs = parseStringArray(yaml.args);
275
505
  const guardrails = isRecord(yaml.guardrails) ? yaml.guardrails : {};
276
506
 
277
507
  return {
278
508
  frontmatter: {
279
509
  commands,
510
+ ...(parsedArgs.values.length > 0 ? { args: parsedArgs.values } : {}),
280
511
  maxIterations: Number(yaml.max_iterations ?? 50),
512
+ interIterationDelay: Number(yaml.inter_iteration_delay ?? 0),
281
513
  timeout: Number(yaml.timeout ?? 300),
282
514
  completionPromise:
283
515
  typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
516
+ requiredOutputs: toStringArray(yaml.required_outputs),
284
517
  guardrails: {
285
518
  blockCommands: toStringArray(guardrails.block_commands),
286
519
  protectedFiles: toStringArray(guardrails.protected_files),
287
520
  },
288
521
  invalidCommandEntries: invalidCommandEntries.length > 0 ? invalidCommandEntries : undefined,
522
+ ...(parsedArgs.invalidEntries ? { invalidArgEntries: parsedArgs.invalidEntries } : {}),
289
523
  },
290
524
  body: match[2] ?? "",
291
525
  };
@@ -295,11 +529,40 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
295
529
  if ((fm.invalidCommandEntries?.length ?? 0) > 0) {
296
530
  return `Invalid command entry at index ${fm.invalidCommandEntries![0]}`;
297
531
  }
298
- if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
299
- return "Invalid max_iterations: must be a positive finite integer";
532
+ if ((fm.invalidArgEntries?.length ?? 0) > 0) {
533
+ return `Invalid args entry at index ${fm.invalidArgEntries![0]}`;
300
534
  }
301
- if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
302
- return "Invalid timeout: must be a positive finite number";
535
+ if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations < 1 || fm.maxIterations > 50) {
536
+ return "Invalid max_iterations: must be between 1 and 50";
537
+ }
538
+ if (!Number.isFinite(fm.interIterationDelay) || !Number.isInteger(fm.interIterationDelay) || fm.interIterationDelay < 0) {
539
+ return "Invalid inter_iteration_delay: must be a non-negative integer";
540
+ }
541
+ if (!Number.isFinite(fm.timeout) || fm.timeout <= 0 || fm.timeout > 300) {
542
+ return "Invalid timeout: must be greater than 0 and at most 300";
543
+ }
544
+ if (fm.completionPromise !== undefined && !isSafeCompletionPromise(fm.completionPromise)) {
545
+ return "Invalid completion_promise: must be a single-line string without line breaks or angle brackets";
546
+ }
547
+ const args = fm.args ?? [];
548
+ const seenArgNames = new Set<string>();
549
+ for (const arg of args) {
550
+ if (!arg.trim()) {
551
+ return "Invalid arg: name is required";
552
+ }
553
+ if (!/^\w[\w-]*$/.test(arg)) {
554
+ return `Invalid arg name: ${arg} must match ^\\w[\\w-]*$`;
555
+ }
556
+ if (seenArgNames.has(arg)) {
557
+ return "Invalid args: names must be unique";
558
+ }
559
+ seenArgNames.add(arg);
560
+ }
561
+ for (const output of fm.requiredOutputs ?? []) {
562
+ const requiredOutputError = validateRequiredOutputEntry(output);
563
+ if (requiredOutputError) {
564
+ return requiredOutputError;
565
+ }
303
566
  }
304
567
  for (const pattern of fm.guardrails.blockCommands) {
305
568
  try {
@@ -308,20 +571,118 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
308
571
  return `Invalid block_commands regex: ${pattern}`;
309
572
  }
310
573
  }
574
+ for (const pattern of fm.guardrails.protectedFiles) {
575
+ if (isUniversalProtectedGlob(pattern)) {
576
+ return `Invalid protected_files glob: ${pattern}`;
577
+ }
578
+ }
311
579
  for (const cmd of fm.commands) {
312
580
  if (!cmd.name.trim()) {
313
581
  return "Invalid command: name is required";
314
582
  }
583
+ if (!/^\w[\w-]*$/.test(cmd.name)) {
584
+ return `Invalid command name: ${cmd.name} must match ^\\w[\\w-]*$`;
585
+ }
315
586
  if (!cmd.run.trim()) {
316
587
  return `Invalid command ${cmd.name}: run is required`;
317
588
  }
318
- if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
319
- return `Invalid command ${cmd.name}: timeout must be positive`;
589
+ if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0 || cmd.timeout > 300) {
590
+ return `Invalid command ${cmd.name}: timeout must be greater than 0 and at most 300`;
591
+ }
592
+ if (cmd.timeout > fm.timeout) {
593
+ return `Invalid command ${cmd.name}: timeout must not exceed top-level timeout`;
320
594
  }
321
595
  }
322
596
  return null;
323
597
  }
324
598
 
599
+ function parseCompletionPromiseValue(yaml: UnknownRecord): { present: boolean; value?: string; invalid: boolean } {
600
+ if (!Object.prototype.hasOwnProperty.call(yaml, "completion_promise")) {
601
+ return { present: false, invalid: false };
602
+ }
603
+ const value = yaml.completion_promise;
604
+ if (typeof value !== "string" || !value.trim() || !isSafeCompletionPromise(value)) {
605
+ return { present: true, invalid: true };
606
+ }
607
+ return { present: true, value, invalid: false };
608
+ }
609
+
610
+ export function acceptStrengthenedDraft(request: DraftRequest, strengthenedDraft: string): DraftPlan | null {
611
+ const baseline = parseStrictRalphMarkdown(request.baselineDraft);
612
+ const strengthened = parseStrictRalphMarkdown(strengthenedDraft);
613
+ if ("error" in baseline || "error" in strengthened) {
614
+ return null;
615
+ }
616
+
617
+ const validationError = validateFrontmatter(strengthened.parsed.frontmatter);
618
+ if (validationError) {
619
+ return null;
620
+ }
621
+
622
+ const baselineRequiredOutputs = baseline.parsed.frontmatter.requiredOutputs ?? [];
623
+ const strengthenedRequiredOutputs = strengthened.parsed.frontmatter.requiredOutputs ?? [];
624
+ if (baselineRequiredOutputs.join("\n") !== strengthenedRequiredOutputs.join("\n")) {
625
+ return null;
626
+ }
627
+
628
+ const baselineArgs = baseline.parsed.frontmatter.args ?? [];
629
+ const strengthenedArgs = strengthened.parsed.frontmatter.args ?? [];
630
+ if (baselineArgs.join("\n") !== strengthenedArgs.join("\n")) {
631
+ return null;
632
+ }
633
+
634
+ const baselineCompletion = parseCompletionPromiseValue(baseline.rawFrontmatter);
635
+ const strengthenedCompletion = parseCompletionPromiseValue(strengthened.rawFrontmatter);
636
+ if (baselineCompletion.invalid || strengthenedCompletion.invalid) {
637
+ return null;
638
+ }
639
+ if (baselineCompletion.present !== strengthenedCompletion.present || baselineCompletion.value !== strengthenedCompletion.value) {
640
+ return null;
641
+ }
642
+
643
+ if (baseline.parsed.frontmatter.maxIterations < strengthened.parsed.frontmatter.maxIterations) {
644
+ return null;
645
+ }
646
+ if (baseline.parsed.frontmatter.timeout < strengthened.parsed.frontmatter.timeout) {
647
+ return null;
648
+ }
649
+ if (
650
+ baseline.parsed.frontmatter.guardrails.blockCommands.join("\n") !== strengthened.parsed.frontmatter.guardrails.blockCommands.join("\n") ||
651
+ baseline.parsed.frontmatter.guardrails.protectedFiles.join("\n") !== strengthened.parsed.frontmatter.guardrails.protectedFiles.join("\n")
652
+ ) {
653
+ return null;
654
+ }
655
+
656
+ const baselineCommands = new Map(baseline.parsed.frontmatter.commands.map((command) => [command.name, command]));
657
+ const seenCommands = new Set<string>();
658
+ for (const command of strengthened.parsed.frontmatter.commands) {
659
+ if (seenCommands.has(command.name)) {
660
+ return null;
661
+ }
662
+ seenCommands.add(command.name);
663
+
664
+ const baselineCommand = baselineCommands.get(command.name);
665
+ if (!baselineCommand || baselineCommand.run !== command.run) {
666
+ return null;
667
+ }
668
+ if (command.timeout > baselineCommand.timeout || command.timeout > strengthened.parsed.frontmatter.timeout) {
669
+ return null;
670
+ }
671
+ }
672
+
673
+ for (const placeholder of strengthened.parsed.body.matchAll(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g)) {
674
+ if (!seenCommands.has(placeholder[1])) {
675
+ return null;
676
+ }
677
+ }
678
+
679
+ if (collectArgPlaceholderNames(strengthened.parsed.body).length > 0) {
680
+ return null;
681
+ }
682
+
683
+ return renderDraftPlan(request.task, request.mode, request.target, strengthened.parsed.frontmatter, "llm-strengthened", strengthened.parsed.body);
684
+ }
685
+
325
686
  export function findBlockedCommandPattern(command: string, blockPatterns: string[]): string | undefined {
326
687
  for (const pattern of blockPatterns) {
327
688
  try {
@@ -333,13 +694,231 @@ export function findBlockedCommandPattern(command: string, blockPatterns: string
333
694
  return undefined;
334
695
  }
335
696
 
697
+ function hasRuntimeArgToken(text: string): boolean {
698
+ return /(?:^|\s)--arg(?:\s|=)/.test(text);
699
+ }
700
+
701
+ function parseRuntimeArgEntry(token: string): { entry?: RuntimeArg; error?: string } {
702
+ const equalsIndex = token.indexOf("=");
703
+ if (equalsIndex < 0) {
704
+ return { error: "Invalid --arg entry: name=value is required" };
705
+ }
706
+
707
+ const name = token.slice(0, equalsIndex).trim();
708
+ const value = token.slice(equalsIndex + 1);
709
+ if (!name) {
710
+ return { error: "Invalid --arg entry: name is required" };
711
+ }
712
+ if (!value) {
713
+ return { error: "Invalid --arg entry: value is required" };
714
+ }
715
+
716
+ return { entry: { name, value } };
717
+ }
718
+
719
+ function parseExplicitPathRuntimeArgs(rawTail: string): { runtimeArgs: RuntimeArg[]; error?: string } {
720
+ const runtimeArgs: RuntimeArg[] = [];
721
+ const trimmed = rawTail.trim();
722
+ if (!trimmed) {
723
+ return { runtimeArgs };
724
+ }
725
+
726
+ const syntaxError = "Invalid --arg syntax: values must be a single token and no trailing text is allowed";
727
+ let index = 0;
728
+
729
+ while (index < trimmed.length) {
730
+ while (index < trimmed.length && /\s/.test(trimmed[index])) {
731
+ index += 1;
732
+ }
733
+ if (index >= trimmed.length) {
734
+ break;
735
+ }
736
+
737
+ if (!trimmed.startsWith("--arg", index) || (trimmed[index + 5] !== undefined && !/\s/.test(trimmed[index + 5]))) {
738
+ return { runtimeArgs, error: syntaxError };
739
+ }
740
+ index += 5;
741
+
742
+ while (index < trimmed.length && /\s/.test(trimmed[index])) {
743
+ index += 1;
744
+ }
745
+ if (index >= trimmed.length) {
746
+ return { runtimeArgs, error: "Invalid --arg entry: name=value is required" };
747
+ }
748
+
749
+ const nameStart = index;
750
+ while (index < trimmed.length && trimmed[index] !== "=" && !/\s/.test(trimmed[index])) {
751
+ index += 1;
752
+ }
753
+
754
+ const name = trimmed.slice(nameStart, index).trim();
755
+ if (!name) {
756
+ return { runtimeArgs, error: "Invalid --arg entry: name is required" };
757
+ }
758
+ if (index >= trimmed.length || trimmed[index] !== "=") {
759
+ return { runtimeArgs, error: "Invalid --arg entry: name=value is required" };
760
+ }
761
+ index += 1;
762
+
763
+ if (index >= trimmed.length || /\s/.test(trimmed[index])) {
764
+ return { runtimeArgs, error: "Invalid --arg entry: value is required" };
765
+ }
766
+
767
+ let value = "";
768
+ const quote = trimmed[index];
769
+ if (quote === "'" || quote === '"') {
770
+ index += 1;
771
+ while (index < trimmed.length && trimmed[index] !== quote) {
772
+ value += trimmed[index];
773
+ index += 1;
774
+ }
775
+ if (index >= trimmed.length) {
776
+ return { runtimeArgs, error: syntaxError };
777
+ }
778
+ if (index + 1 < trimmed.length && !/\s/.test(trimmed[index + 1])) {
779
+ return { runtimeArgs, error: syntaxError };
780
+ }
781
+ index += 1;
782
+ } else {
783
+ while (index < trimmed.length && !/\s/.test(trimmed[index])) {
784
+ const char = trimmed[index];
785
+ if (char === "'" || char === '"') {
786
+ return { runtimeArgs, error: syntaxError };
787
+ }
788
+ value += char;
789
+ index += 1;
790
+ }
791
+ }
792
+
793
+ const parsed = parseRuntimeArgEntry(`${name}=${value}`);
794
+ if (parsed.error) {
795
+ return { runtimeArgs, error: parsed.error };
796
+ }
797
+
798
+ const entry = parsed.entry;
799
+ if (!entry) {
800
+ return { runtimeArgs, error: "Invalid --arg entry: name=value is required" };
801
+ }
802
+
803
+ if (runtimeArgs.some((existing) => existing.name === entry.name)) {
804
+ return { runtimeArgs, error: `Duplicate --arg: ${entry.name}` };
805
+ }
806
+
807
+ runtimeArgs.push(entry);
808
+
809
+ while (index < trimmed.length && /\s/.test(trimmed[index])) {
810
+ index += 1;
811
+ }
812
+ if (index < trimmed.length && !trimmed.startsWith("--arg", index)) {
813
+ return { runtimeArgs, error: syntaxError };
814
+ }
815
+ }
816
+
817
+ return { runtimeArgs };
818
+ }
819
+
820
+ function parseExplicitPathCommandArgs(valueWithArgs: string): CommandArgs {
821
+ const argMatch = valueWithArgs.match(/(?:^|\s)--arg(?:\s|=|[^\s=]*=|$)/);
822
+ const argIndex = argMatch?.index ?? valueWithArgs.length;
823
+ const value = argMatch ? valueWithArgs.slice(0, argIndex).trim() : valueWithArgs.trim();
824
+ const parsedArgs = parseExplicitPathRuntimeArgs(argMatch ? valueWithArgs.slice(argIndex).trim() : "");
825
+ return { mode: "path", value, runtimeArgs: parsedArgs.runtimeArgs, error: parsedArgs.error ?? undefined };
826
+ }
827
+
336
828
  export function parseCommandArgs(raw: string): CommandArgs {
337
- const trimmed = raw.trim();
338
- if (trimmed.startsWith("--task=")) return { mode: "task", value: trimmed.slice("--task=".length).trim() };
339
- if (trimmed.startsWith("--path=")) return { mode: "path", value: trimmed.slice("--path=".length).trim() };
340
- if (trimmed.startsWith("--task ")) return { mode: "task", value: trimmed.slice("--task ".length).trim() };
341
- if (trimmed.startsWith("--path ")) return { mode: "path", value: trimmed.slice("--path ".length).trim() };
342
- return { mode: "auto", value: trimmed };
829
+ const cleaned = raw.trim();
830
+
831
+ if (cleaned.startsWith("--task=")) {
832
+ const value = cleaned.slice("--task=".length).trim();
833
+ if (hasRuntimeArgToken(value)) {
834
+ return { mode: "task", value, runtimeArgs: [], error: "--arg is only supported with /ralph --path" };
835
+ }
836
+ return { mode: "task", value, runtimeArgs: [], error: undefined };
837
+ }
838
+ if (cleaned.startsWith("--task ")) {
839
+ const value = cleaned.slice("--task ".length).trim();
840
+ if (hasRuntimeArgToken(value)) {
841
+ return { mode: "task", value, runtimeArgs: [], error: "--arg is only supported with /ralph --path" };
842
+ }
843
+ return { mode: "task", value, runtimeArgs: [], error: undefined };
844
+ }
845
+ if (cleaned.startsWith("--path=")) {
846
+ return parseExplicitPathCommandArgs(cleaned.slice("--path=".length).trimStart());
847
+ }
848
+ if (cleaned.startsWith("--path ")) {
849
+ return parseExplicitPathCommandArgs(cleaned.slice("--path ".length).trimStart());
850
+ }
851
+ return { mode: "auto", value: cleaned, runtimeArgs: [], error: undefined };
852
+ }
853
+
854
+ export function runtimeArgEntriesToMap(entries: RuntimeArg[]): { runtimeArgs: RuntimeArgs; error?: string } {
855
+ const runtimeArgs = Object.create(null) as RuntimeArgs;
856
+ for (const entry of entries) {
857
+ if (!entry.name.trim()) {
858
+ return { runtimeArgs, error: "Invalid --arg entry: name is required" };
859
+ }
860
+ if (!entry.value.trim()) {
861
+ return { runtimeArgs, error: "Invalid --arg entry: value is required" };
862
+ }
863
+ if (!/^\w[\w-]*$/.test(entry.name)) {
864
+ return { runtimeArgs, error: `Invalid --arg name: ${entry.name} must match ^\\w[\\w-]*$` };
865
+ }
866
+ if (Object.prototype.hasOwnProperty.call(runtimeArgs, entry.name)) {
867
+ return { runtimeArgs, error: `Duplicate --arg: ${entry.name}` };
868
+ }
869
+ runtimeArgs[entry.name] = entry.value;
870
+ }
871
+ return { runtimeArgs };
872
+ }
873
+
874
+ function collectArgPlaceholderNames(source: string): string[] {
875
+ const names = new Set<string>();
876
+ for (const match of source.matchAll(/\{\{\s*args\.(\w[\w-]*)\s*\}\}/g)) {
877
+ names.add(match[1]);
878
+ }
879
+ return [...names];
880
+ }
881
+
882
+ function validateBodyArgsAgainstContract(body: string, declaredArgs: string[] | undefined): string | null {
883
+ const declaredSet = new Set(declaredArgs ?? []);
884
+ for (const name of collectArgPlaceholderNames(body)) {
885
+ if (!declaredSet.has(name)) {
886
+ return `Undeclared arg placeholder: ${name}`;
887
+ }
888
+ }
889
+ return null;
890
+ }
891
+
892
+ export function validateRuntimeArgs(frontmatter: Frontmatter, body: string, commands: CommandDef[], runtimeArgs: RuntimeArgs): string | null {
893
+ const declaredArgs = frontmatter.args ?? [];
894
+ const declaredSet = new Set(declaredArgs);
895
+
896
+ for (const name of Object.keys(runtimeArgs)) {
897
+ if (!declaredSet.has(name)) {
898
+ return `Undeclared arg: ${name}`;
899
+ }
900
+ }
901
+
902
+ for (const name of declaredArgs) {
903
+ if (!Object.prototype.hasOwnProperty.call(runtimeArgs, name)) {
904
+ return `Missing required arg: ${name}`;
905
+ }
906
+ }
907
+
908
+ for (const name of collectArgPlaceholderNames(body)) {
909
+ if (!declaredSet.has(name)) {
910
+ return `Undeclared arg placeholder: ${name}`;
911
+ }
912
+ }
913
+ for (const command of commands) {
914
+ for (const name of collectArgPlaceholderNames(command.run)) {
915
+ if (!declaredSet.has(name)) {
916
+ return `Undeclared arg placeholder: ${name}`;
917
+ }
918
+ }
919
+ }
920
+
921
+ return null;
343
922
  }
344
923
 
345
924
  export function looksLikePath(value: string): boolean {
@@ -555,7 +1134,9 @@ function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontma
555
1134
  return {
556
1135
  commands,
557
1136
  maxIterations: mode === "analysis" ? 12 : mode === "migration" ? 30 : 25,
1137
+ interIterationDelay: 0,
558
1138
  timeout: 300,
1139
+ requiredOutputs: [],
559
1140
  guardrails,
560
1141
  };
561
1142
  }
@@ -590,15 +1171,23 @@ function commandIntentsToCommands(commandIntents: CommandIntent[]): CommandDef[]
590
1171
 
591
1172
  function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, frontmatter: Frontmatter, source: DraftSource, body: string): DraftPlan {
592
1173
  const metadata: DraftMetadata = { generator: "pi-ralph-loop", version: 2, source, task, mode };
1174
+ const requiredOutputs = frontmatter.requiredOutputs ?? [];
593
1175
  const frontmatterLines = [
594
1176
  ...renderCommandsYaml(frontmatter.commands),
595
1177
  `max_iterations: ${frontmatter.maxIterations}`,
1178
+ `inter_iteration_delay: ${frontmatter.interIterationDelay}`,
596
1179
  `timeout: ${frontmatter.timeout}`,
1180
+ ...(requiredOutputs.length > 0
1181
+ ? ["required_outputs:", ...requiredOutputs.map((output) => ` - ${yamlQuote(output)}`)]
1182
+ : []),
1183
+ ...(frontmatter.completionPromise ? [`completion_promise: ${yamlQuote(frontmatter.completionPromise)}`] : []),
597
1184
  "guardrails:",
598
- " block_commands:",
599
- ...frontmatter.guardrails.blockCommands.map((pattern) => ` - ${yamlQuote(pattern)}`),
600
- " protected_files:",
601
- ...frontmatter.guardrails.protectedFiles.map((pattern) => ` - ${yamlQuote(pattern)}`),
1185
+ ...(frontmatter.guardrails.blockCommands.length > 0
1186
+ ? [" block_commands:", ...frontmatter.guardrails.blockCommands.map((pattern) => ` - ${yamlQuote(pattern)}`)]
1187
+ : [" block_commands: []"]),
1188
+ ...(frontmatter.guardrails.protectedFiles.length > 0
1189
+ ? [" protected_files:", ...frontmatter.guardrails.protectedFiles.map((pattern) => ` - ${yamlQuote(pattern)}`)]
1190
+ : [" protected_files: []"]),
602
1191
  ];
603
1192
 
604
1193
  return {
@@ -609,7 +1198,7 @@ function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, fro
609
1198
  content: `${metadataComment(metadata)}\n${yamlBlock(frontmatterLines)}\n\n${body}`,
610
1199
  commandLabels: frontmatter.commands.map(formatCommandLabel),
611
1200
  safetyLabel: summarizeSafetyLabel(frontmatter.guardrails),
612
- finishLabel: summarizeFinishLabel(frontmatter.maxIterations),
1201
+ finishLabel: summarizeFinishLabel(frontmatter),
613
1202
  };
614
1203
  }
615
1204
 
@@ -635,16 +1224,29 @@ export function buildDraftRequest(task: string, target: DraftTarget, repoSignals
635
1224
 
636
1225
  export function normalizeStrengthenedDraft(request: DraftRequest, strengthenedDraft: string, scope: DraftStrengtheningScope): DraftPlan {
637
1226
  const baseline = parseRalphMarkdown(request.baselineDraft);
638
- const strengthened = parseRalphMarkdown(strengthenedDraft);
1227
+ const strengthened = parseStrictRalphMarkdown(strengthenedDraft);
639
1228
 
640
1229
  if (scope === "body-only") {
641
- return renderDraftPlan(request.task, request.mode, request.target, baseline.frontmatter, "llm-strengthened", strengthened.body);
1230
+ if (
1231
+ "error" in strengthened ||
1232
+ validateFrontmatter(strengthened.parsed.frontmatter) ||
1233
+ validateBodyArgsAgainstContract(strengthened.parsed.body, baseline.frontmatter.args)
1234
+ ) {
1235
+ return renderDraftPlan(request.task, request.mode, request.target, baseline.frontmatter, "llm-strengthened", baseline.body);
1236
+ }
1237
+
1238
+ return renderDraftPlan(request.task, request.mode, request.target, baseline.frontmatter, "llm-strengthened", strengthened.parsed.body);
642
1239
  }
643
1240
 
644
- return renderDraftPlan(request.task, request.mode, request.target, strengthened.frontmatter, "llm-strengthened", strengthened.body);
1241
+ const accepted = acceptStrengthenedDraft(request, strengthenedDraft);
1242
+ if (accepted) {
1243
+ return accepted;
1244
+ }
1245
+
1246
+ return renderDraftPlan(request.task, request.mode, request.target, baseline.frontmatter, "llm-strengthened", baseline.body);
645
1247
  }
646
1248
 
647
- function hasFakeRuntimeEnforcementClaim(text: string): boolean {
1249
+ export function hasFakeRuntimeEnforcementClaim(text: string): boolean {
648
1250
  return /read[-\s]?only enforced|write protection is enforced/i.test(text);
649
1251
  }
650
1252
 
@@ -692,20 +1294,19 @@ export type DraftContentInspection = {
692
1294
 
693
1295
  export function inspectDraftContent(raw: string): DraftContentInspection {
694
1296
  const metadata = extractDraftMetadata(raw);
695
- const normalized = normalizeRawRalph(raw);
1297
+ const parsed = parseStrictRalphMarkdown(raw);
696
1298
 
697
- if (!hasRalphFrontmatter(normalized)) {
698
- return { metadata, error: "Missing RALPH frontmatter" };
1299
+ if ("error" in parsed) {
1300
+ return { metadata, error: parsed.error };
699
1301
  }
700
1302
 
701
- try {
702
- const parsed = parseRalphMarkdown(normalized);
703
- const error = validateFrontmatter(parsed.frontmatter);
704
- return error ? { metadata, parsed, error } : { metadata, parsed };
705
- } catch (err) {
706
- const message = err instanceof Error ? err.message : String(err);
707
- return { metadata, error: `Invalid RALPH frontmatter: ${message}` };
1303
+ const rawCompletionPromise = parseCompletionPromiseValue(parsed.rawFrontmatter);
1304
+ if (rawCompletionPromise.invalid) {
1305
+ return { metadata, parsed: parsed.parsed, error: "Invalid completion_promise: must be a single-line string without line breaks or angle brackets" };
708
1306
  }
1307
+
1308
+ const error = validateFrontmatter(parsed.parsed.frontmatter);
1309
+ return error ? { metadata, parsed: parsed.parsed, error } : { metadata, parsed: parsed.parsed };
709
1310
  }
710
1311
 
711
1312
  export function validateDraftContent(raw: string): string | null {
@@ -734,9 +1335,8 @@ export function buildMissionBrief(plan: DraftPlan): string {
734
1335
  }
735
1336
 
736
1337
  const parsed = inspection.parsed!;
737
- const mode = inspection.metadata?.mode ?? "general";
738
1338
  const commandLabels = parsed.frontmatter.commands.map(formatCommandLabel);
739
- const finishLabel = summarizeFinishLabel(parsed.frontmatter.maxIterations);
1339
+ const finishBehavior = summarizeFinishBehavior(parsed.frontmatter);
740
1340
  const safetyLabel = summarizeSafetyLabel(parsed.frontmatter.guardrails);
741
1341
 
742
1342
  return [
@@ -753,7 +1353,7 @@ export function buildMissionBrief(plan: DraftPlan): string {
753
1353
  ...commandLabels.map((label) => `- ${label}`),
754
1354
  "",
755
1355
  "Finish behavior",
756
- `- ${finishLabel}`,
1356
+ ...finishBehavior,
757
1357
  "",
758
1358
  "Safety",
759
1359
  `- ${safetyLabel}`,
@@ -769,20 +1369,77 @@ export function shouldStopForCompletionPromise(text: string, expected: string):
769
1369
  return extractCompletionPromise(text) === expected.trim();
770
1370
  }
771
1371
 
772
- export function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
1372
+ function shellQuote(value: string): string {
1373
+ return "'" + value.split("'").join("'\\''") + "'";
1374
+ }
1375
+
1376
+ export function replaceArgsPlaceholders(text: string, runtimeArgs: RuntimeArgs, shellSafe = false): string {
1377
+ return text.replace(/\{\{\s*args\.(\w[\w-]*)\s*\}\}/g, (_, name) => {
1378
+ if (!Object.prototype.hasOwnProperty.call(runtimeArgs, name)) {
1379
+ throw new Error(`Missing required arg: ${name}`);
1380
+ }
1381
+ const value = runtimeArgs[name];
1382
+ return shellSafe ? shellQuote(value) : value;
1383
+ });
1384
+ }
1385
+
1386
+ export function resolvePlaceholders(
1387
+ body: string,
1388
+ outputs: CommandOutput[],
1389
+ ralph: { iteration: number; name: string; maxIterations: number },
1390
+ runtimeArgs: RuntimeArgs = {},
1391
+ ): string {
773
1392
  const map = new Map(outputs.map((o) => [o.name, o.output]));
774
- return body
775
- .replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
776
- .replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
777
- .replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name);
1393
+ const resolved = replaceArgsPlaceholders(
1394
+ body
1395
+ .replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
1396
+ .replace(/\{\{\s*ralph\.name\s*\}\}/g, ralph.name)
1397
+ .replace(/\{\{\s*ralph\.max_iterations\s*\}\}/g, String(ralph.maxIterations)),
1398
+ runtimeArgs,
1399
+ );
1400
+ return resolved.replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "");
1401
+ }
1402
+
1403
+ export function resolveCommandRun(run: string, runtimeArgs: RuntimeArgs): string {
1404
+ return replaceArgsPlaceholders(run, runtimeArgs, true);
778
1405
  }
779
1406
 
780
- export function renderRalphBody(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
781
- return resolvePlaceholders(body, outputs, ralph).replace(/<!--[\s\S]*?-->/g, "");
1407
+ export function renderRalphBody(
1408
+ body: string,
1409
+ outputs: CommandOutput[],
1410
+ ralph: { iteration: number; name: string; maxIterations: number },
1411
+ runtimeArgs: RuntimeArgs = {},
1412
+ ): string {
1413
+ return resolvePlaceholders(body, outputs, ralph, runtimeArgs).replace(/<!--[\s\S]*?-->/g, "");
782
1414
  }
783
1415
 
784
- export function renderIterationPrompt(body: string, iteration: number, maxIterations: number): string {
785
- return `[ralph: iteration ${iteration}/${maxIterations}]\n\n${body}`;
1416
+ export function renderIterationPrompt(
1417
+ body: string,
1418
+ iteration: number,
1419
+ maxIterations: number,
1420
+ completionGate?: { completionPromise?: string; requiredOutputs?: string[]; failureReasons?: string[]; rejectionReasons?: string[] },
1421
+ ): string {
1422
+ if (!completionGate) {
1423
+ return `[ralph: iteration ${iteration}/${maxIterations}]\n\n${body}`;
1424
+ }
1425
+
1426
+ const requiredOutputs = completionGate.requiredOutputs ?? [];
1427
+ const failureReasons = completionGate.failureReasons ?? [];
1428
+ const rejectionReasons = completionGate.rejectionReasons ?? [];
1429
+ const completionPromise = completionGate.completionPromise ?? "DONE";
1430
+ const gateLines = [
1431
+ "[completion gate]",
1432
+ `- Required outputs must exist before stopping${requiredOutputs.length > 0 ? `: ${requiredOutputs.join(", ")}` : "."}`,
1433
+ "- OPEN_QUESTIONS.md must have no remaining P0/P1 items before stopping.",
1434
+ "- Label inferred claims as HYPOTHESIS.",
1435
+ ...(rejectionReasons.length > 0
1436
+ ? ["[completion gate rejection]", `- Still missing: ${rejectionReasons.join("; ")}`]
1437
+ : []),
1438
+ ...(failureReasons.length > 0 ? [`- Previous gate failures: ${failureReasons.join("; ")}`] : []),
1439
+ `- Emit <promise>${completionPromise}</promise> only when the gate is truly satisfied.`,
1440
+ ];
1441
+
1442
+ return `[ralph: iteration ${iteration}/${maxIterations}]\n\n${body}\n\n${gateLines.join("\n")}`;
786
1443
  }
787
1444
 
788
1445
  export function shouldWarnForBashFailure(output: string): boolean {