@lnilluv/pi-ralph-loop 0.3.0 → 1.0.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 +50 -160
  3. package/package.json +2 -2
  4. package/scripts/version-helper.ts +210 -0
  5. package/src/index.ts +1085 -188
  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 +917 -102
  10. package/src/runner-rpc.ts +434 -0
  11. package/src/runner-state.ts +822 -0
  12. package/src/runner.ts +957 -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 +3529 -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 +1389 -19
  41. package/tests/runner-event-contract.test.ts +235 -0
  42. package/tests/runner-rpc.test.ts +358 -0
  43. package/tests/runner-state.test.ts +553 -0
  44. package/tests/runner.test.ts +1347 -0
  45. package/tests/secret-paths.test.ts +55 -0
  46. package/tests/version-helper.test.ts +75 -0
package/src/ralph.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { basename, dirname, join, resolve } from "node:path";
3
3
  import { parse as parseYaml } from "yaml";
4
+ import { SECRET_PATH_POLICY_TOKEN, filterSecretBearingTopLevelNames, isSecretBearingPath, isSecretBearingTopLevelName } from "./secret-paths.ts";
4
5
 
5
6
  export type CommandDef = { name: string; run: string; timeout: number };
7
+ export type DraftSource = "deterministic" | "llm-strengthened" | "fallback";
8
+ export type DraftStrengtheningScope = "body-only" | "body-and-commands";
9
+ export type CommandIntent = CommandDef & { source: "heuristic" | "repo-signal" };
10
+ export type RuntimeArg = { name: string; value: string };
11
+ export type RuntimeArgs = Record<string, string>;
6
12
  export type Frontmatter = {
7
13
  commands: CommandDef[];
14
+ args?: string[];
8
15
  maxIterations: number;
16
+ interIterationDelay: number;
9
17
  timeout: number;
10
18
  completionPromise?: string;
19
+ requiredOutputs?: string[];
11
20
  guardrails: { blockCommands: string[]; protectedFiles: string[] };
12
21
  invalidCommandEntries?: number[];
22
+ invalidArgEntries?: number[];
13
23
  };
14
24
  export type ParsedRalph = { frontmatter: Frontmatter; body: string };
15
25
  export type CommandOutput = { name: string; output: string };
@@ -18,9 +28,12 @@ export type RalphTargetResolution = {
18
28
  absoluteTarget: string;
19
29
  markdownPath: string;
20
30
  };
21
- export type CommandArgs =
22
- | { mode: "path" | "task"; value: string }
23
- | { mode: "auto"; value: string };
31
+ export type CommandArgs = {
32
+ mode: "path" | "task" | "auto";
33
+ value: string;
34
+ runtimeArgs: RuntimeArg[];
35
+ error?: string;
36
+ };
24
37
  export type ExistingTargetInspection =
25
38
  | { kind: "run"; ralphPath: string }
26
39
  | { kind: "invalid-markdown"; path: string }
@@ -29,12 +42,20 @@ export type ExistingTargetInspection =
29
42
  | { kind: "missing-path"; dirPath: string; ralphPath: string }
30
43
  | { kind: "not-path" };
31
44
  export type DraftMode = "analysis" | "fix" | "migration" | "general";
32
- export type DraftMetadata = {
33
- generator: "pi-ralph-loop";
34
- version: 1;
35
- task: string;
36
- mode: DraftMode;
37
- };
45
+ export type DraftMetadata =
46
+ | {
47
+ generator: "pi-ralph-loop";
48
+ version: 1;
49
+ task: string;
50
+ mode: DraftMode;
51
+ }
52
+ | {
53
+ generator: "pi-ralph-loop";
54
+ version: 2;
55
+ source: DraftSource;
56
+ task: string;
57
+ mode: DraftMode;
58
+ };
38
59
  export type DraftTarget = {
39
60
  slug: string;
40
61
  dirPath: string;
@@ -51,10 +72,29 @@ export type RepoSignals = {
51
72
  topLevelDirs: string[];
52
73
  topLevelFiles: string[];
53
74
  };
75
+ export type RepoContextSelectedFile = {
76
+ path: string;
77
+ content: string;
78
+ reason: string;
79
+ };
80
+ export type RepoContext = {
81
+ summaryLines: string[];
82
+ selectedFiles: RepoContextSelectedFile[];
83
+ };
84
+ export type DraftRequest = {
85
+ task: string;
86
+ mode: DraftMode;
87
+ target: DraftTarget;
88
+ repoSignals: RepoSignals;
89
+ repoContext: RepoContext;
90
+ commandIntent: CommandIntent[];
91
+ baselineDraft: string;
92
+ };
54
93
  export type DraftPlan = {
55
94
  task: string;
56
95
  mode: DraftMode;
57
96
  target: DraftTarget;
97
+ source: DraftSource;
58
98
  content: string;
59
99
  commandLabels: string[];
60
100
  safetyLabel: string;
@@ -67,6 +107,17 @@ function isRecord(value: unknown): value is UnknownRecord {
67
107
  return typeof value === "object" && value !== null && !Array.isArray(value);
68
108
  }
69
109
 
110
+ const draftModes: DraftMode[] = ["analysis", "fix", "migration", "general"];
111
+ const draftSources: DraftSource[] = ["deterministic", "llm-strengthened", "fallback"];
112
+
113
+ function isDraftMode(value: unknown): value is DraftMode {
114
+ return typeof value === "string" && draftModes.includes(value as DraftMode);
115
+ }
116
+
117
+ function isDraftSource(value: unknown): value is DraftSource {
118
+ return typeof value === "string" && draftSources.includes(value as DraftSource);
119
+ }
120
+
70
121
  function parseRalphFrontmatter(raw: string): UnknownRecord {
71
122
  const parsed: unknown = parseYaml(raw);
72
123
  return isRecord(parsed) ? parsed : {};
@@ -89,6 +140,28 @@ function toStringArray(value: unknown): string[] {
89
140
  return Array.isArray(value) ? value.map((item) => String(item)) : [];
90
141
  }
91
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
+
92
165
  function normalizeRawRalph(raw: string): string {
93
166
  return raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
94
167
  }
@@ -97,8 +170,159 @@ function matchRalphMarkdown(raw: string): RegExpMatchArray | null {
97
170
  return normalizeRawRalph(raw).match(/^(?:\s*<!--[\s\S]*?-->\s*)*---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
98
171
  }
99
172
 
100
- function hasRalphFrontmatter(raw: string): boolean {
101
- 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 };
102
326
  }
103
327
 
104
328
  function normalizeMissingMarkdownTarget(absoluteTarget: string): { dirPath: string; ralphPath: string } {
@@ -117,7 +341,7 @@ function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
117
341
  } else if (guardrails.blockCommands.length > 0) {
118
342
  labels.push(`blocks ${guardrails.blockCommands.length} command pattern${guardrails.blockCommands.length === 1 ? "" : "s"}`);
119
343
  }
120
- if (guardrails.protectedFiles.some((pattern) => pattern.includes(".env") || pattern.includes("secret"))) {
344
+ if (guardrails.protectedFiles.some((pattern) => pattern === SECRET_PATH_POLICY_TOKEN || isSecretBearingPath(pattern))) {
121
345
  labels.push("blocks write/edit to secret files");
122
346
  } else if (guardrails.protectedFiles.length > 0) {
123
347
  labels.push(`blocks write/edit to ${guardrails.protectedFiles.length} file glob${guardrails.protectedFiles.length === 1 ? "" : "s"}`);
@@ -125,8 +349,55 @@ function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
125
349
  return labels.length > 0 ? labels.join(" and ") : "No extra safety rules";
126
350
  }
127
351
 
128
- function summarizeFinishLabel(maxIterations: number): string {
129
- 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;
130
401
  }
131
402
 
132
403
  function isRalphMarkdownPath(path: string): boolean {
@@ -212,7 +483,7 @@ function escapeHtmlCommentMarkers(text: string): string {
212
483
  }
213
484
 
214
485
  export function defaultFrontmatter(): Frontmatter {
215
- return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
486
+ return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], guardrails: { blockCommands: [], protectedFiles: [] } };
216
487
  }
217
488
 
218
489
  export function parseRalphMarkdown(raw: string): ParsedRalph {
@@ -230,20 +501,25 @@ export function parseRalphMarkdown(raw: string): ParsedRalph {
230
501
  }
231
502
  return [parsed];
232
503
  });
504
+ const parsedArgs = parseStringArray(yaml.args);
233
505
  const guardrails = isRecord(yaml.guardrails) ? yaml.guardrails : {};
234
506
 
235
507
  return {
236
508
  frontmatter: {
237
509
  commands,
510
+ ...(parsedArgs.values.length > 0 ? { args: parsedArgs.values } : {}),
238
511
  maxIterations: Number(yaml.max_iterations ?? 50),
512
+ interIterationDelay: Number(yaml.inter_iteration_delay ?? 0),
239
513
  timeout: Number(yaml.timeout ?? 300),
240
514
  completionPromise:
241
515
  typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
516
+ requiredOutputs: toStringArray(yaml.required_outputs),
242
517
  guardrails: {
243
518
  blockCommands: toStringArray(guardrails.block_commands),
244
519
  protectedFiles: toStringArray(guardrails.protected_files),
245
520
  },
246
521
  invalidCommandEntries: invalidCommandEntries.length > 0 ? invalidCommandEntries : undefined,
522
+ ...(parsedArgs.invalidEntries ? { invalidArgEntries: parsedArgs.invalidEntries } : {}),
247
523
  },
248
524
  body: match[2] ?? "",
249
525
  };
@@ -253,11 +529,40 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
253
529
  if ((fm.invalidCommandEntries?.length ?? 0) > 0) {
254
530
  return `Invalid command entry at index ${fm.invalidCommandEntries![0]}`;
255
531
  }
256
- if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
257
- 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]}`;
534
+ }
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";
258
540
  }
259
- if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
260
- return "Invalid timeout: must be a positive finite number";
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
+ }
261
566
  }
262
567
  for (const pattern of fm.guardrails.blockCommands) {
263
568
  try {
@@ -266,20 +571,118 @@ export function validateFrontmatter(fm: Frontmatter): string | null {
266
571
  return `Invalid block_commands regex: ${pattern}`;
267
572
  }
268
573
  }
574
+ for (const pattern of fm.guardrails.protectedFiles) {
575
+ if (isUniversalProtectedGlob(pattern)) {
576
+ return `Invalid protected_files glob: ${pattern}`;
577
+ }
578
+ }
269
579
  for (const cmd of fm.commands) {
270
580
  if (!cmd.name.trim()) {
271
581
  return "Invalid command: name is required";
272
582
  }
583
+ if (!/^\w[\w-]*$/.test(cmd.name)) {
584
+ return `Invalid command name: ${cmd.name} must match ^\\w[\\w-]*$`;
585
+ }
273
586
  if (!cmd.run.trim()) {
274
587
  return `Invalid command ${cmd.name}: run is required`;
275
588
  }
276
- if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
277
- 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`;
278
594
  }
279
595
  }
280
596
  return null;
281
597
  }
282
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
+
283
686
  export function findBlockedCommandPattern(command: string, blockPatterns: string[]): string | undefined {
284
687
  for (const pattern of blockPatterns) {
285
688
  try {
@@ -291,13 +694,231 @@ export function findBlockedCommandPattern(command: string, blockPatterns: string
291
694
  return undefined;
292
695
  }
293
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
+
294
828
  export function parseCommandArgs(raw: string): CommandArgs {
295
- const trimmed = raw.trim();
296
- if (trimmed.startsWith("--task=")) return { mode: "task", value: trimmed.slice("--task=".length).trim() };
297
- if (trimmed.startsWith("--path=")) return { mode: "path", value: trimmed.slice("--path=".length).trim() };
298
- if (trimmed.startsWith("--task ")) return { mode: "task", value: trimmed.slice("--task ".length).trim() };
299
- if (trimmed.startsWith("--path ")) return { mode: "path", value: trimmed.slice("--path ".length).trim() };
300
- 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;
301
922
  }
302
923
 
303
924
  export function looksLikePath(value: string): boolean {
@@ -415,8 +1036,9 @@ export function inspectRepo(cwd: string): RepoSignals {
415
1036
 
416
1037
  try {
417
1038
  const entries = readdirSync(cwd, { withFileTypes: true }).slice(0, 50);
418
- topLevelDirs = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).slice(0, 10);
419
- topLevelFiles = entries.filter((entry) => entry.isFile()).map((entry) => entry.name).slice(0, 10);
1039
+ const filteredEntries = entries.filter((entry) => !isSecretBearingTopLevelName(entry.name));
1040
+ topLevelDirs = filteredEntries.filter((entry) => entry.isDirectory()).map((entry) => entry.name).slice(0, 10);
1041
+ topLevelFiles = filteredEntries.filter((entry) => entry.isFile()).map((entry) => entry.name).slice(0, 10);
420
1042
  } catch {
421
1043
  // ignore bounded inspection failures
422
1044
  }
@@ -431,21 +1053,70 @@ export function inspectRepo(cwd: string): RepoSignals {
431
1053
  };
432
1054
  }
433
1055
 
434
- export function suggestedCommandsForMode(mode: DraftMode, signals: RepoSignals): CommandDef[] {
1056
+ export function buildRepoContext(signals: RepoSignals): RepoContext {
1057
+ const topLevelDirs = filterSecretBearingTopLevelNames(signals.topLevelDirs);
1058
+ const topLevelFiles = filterSecretBearingTopLevelNames(signals.topLevelFiles);
1059
+
1060
+ return {
1061
+ summaryLines: [
1062
+ `package manager: ${signals.packageManager ?? "unknown"}`,
1063
+ `test command: ${signals.testCommand ?? "none"}`,
1064
+ `lint command: ${signals.lintCommand ?? "none"}`,
1065
+ `git repository: ${signals.hasGit ? "present" : "absent"}`,
1066
+ `top-level dirs: ${topLevelDirs.length > 0 ? topLevelDirs.join(", ") : "none"}`,
1067
+ `top-level files: ${topLevelFiles.length > 0 ? topLevelFiles.join(", ") : "none"}`,
1068
+ ],
1069
+ selectedFiles: topLevelFiles.slice(0, 10).map((path) => ({
1070
+ path,
1071
+ content: "",
1072
+ reason: "top-level file",
1073
+ })),
1074
+ };
1075
+ }
1076
+
1077
+ function normalizeSelectedFile(file: unknown): RepoContextSelectedFile {
1078
+ if (isRecord(file)) {
1079
+ return {
1080
+ path: String(file.path ?? ""),
1081
+ content: String(file.content ?? ""),
1082
+ reason: String(file.reason ?? "selected file"),
1083
+ };
1084
+ }
1085
+ if (typeof file === "string") {
1086
+ return { path: file, content: "", reason: "selected file" };
1087
+ }
1088
+ return { path: String(file), content: "", reason: "selected file" };
1089
+ }
1090
+
1091
+ function normalizeRepoContext(repoContext: RepoContext | undefined, signals: RepoSignals): RepoContext {
1092
+ if (repoContext && Array.isArray(repoContext.summaryLines) && Array.isArray(repoContext.selectedFiles)) {
1093
+ return {
1094
+ summaryLines: repoContext.summaryLines.map((line) => String(line)),
1095
+ selectedFiles: repoContext.selectedFiles.map((file) => normalizeSelectedFile(file)),
1096
+ };
1097
+ }
1098
+ return buildRepoContext(signals);
1099
+ }
1100
+
1101
+ export function buildCommandIntent(mode: DraftMode, signals: RepoSignals): CommandIntent[] {
435
1102
  if (mode === "analysis") {
436
- const commands: CommandDef[] = [{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 }];
437
- if (signals.hasGit) commands.unshift({ name: "git-log", run: "git log --oneline -10", timeout: 20 });
1103
+ const commands: CommandIntent[] = [{ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20, source: "heuristic" }];
1104
+ if (signals.hasGit) commands.unshift({ name: "git-log", run: "git log --oneline -10", timeout: 20, source: "heuristic" });
438
1105
  return commands;
439
1106
  }
440
1107
 
441
- const commands: CommandDef[] = [];
442
- if (signals.testCommand) commands.push({ name: "tests", run: signals.testCommand, timeout: 120 });
443
- if (signals.lintCommand) commands.push({ name: "lint", run: signals.lintCommand, timeout: 90 });
444
- if (signals.hasGit) commands.push({ name: "git-log", run: "git log --oneline -10", timeout: 20 });
445
- if (commands.length === 0) commands.push({ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20 });
1108
+ const commands: CommandIntent[] = [];
1109
+ if (signals.testCommand) commands.push({ name: "tests", run: signals.testCommand, timeout: 120, source: "repo-signal" });
1110
+ if (signals.lintCommand) commands.push({ name: "lint", run: signals.lintCommand, timeout: 90, source: "repo-signal" });
1111
+ if (signals.hasGit) commands.push({ name: "git-log", run: "git log --oneline -10", timeout: 20, source: "heuristic" });
1112
+ if (commands.length === 0) commands.push({ name: "repo-map", run: "find . -maxdepth 2 -type f | sort | head -n 120", timeout: 20, source: "heuristic" });
446
1113
  return commands;
447
1114
  }
448
1115
 
1116
+ export function suggestedCommandsForMode(mode: DraftMode, signals: RepoSignals): CommandDef[] {
1117
+ return buildCommandIntent(mode, signals).map(({ source: _source, ...command }) => command);
1118
+ }
1119
+
449
1120
  function formatCommandLabel(command: CommandDef): string {
450
1121
  return `${command.name}: ${command.run}`;
451
1122
  }
@@ -455,68 +1126,157 @@ function extractVisibleTask(body: string): string | undefined {
455
1126
  return match?.[1]?.trim() || undefined;
456
1127
  }
457
1128
 
458
- export function generateDraft(task: string, target: DraftTarget, signals: RepoSignals): DraftPlan {
459
- const mode = classifyTaskMode(task);
460
- const commands = suggestedCommandsForMode(mode, signals);
461
- const metadata: DraftMetadata = { generator: "pi-ralph-loop", version: 1, task, mode };
1129
+ function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontmatter {
462
1130
  const guardrails = {
463
1131
  blockCommands: ["git\\s+push"],
464
- protectedFiles: mode === "analysis" ? [] : [".env*", "**/secrets/**"],
1132
+ protectedFiles: mode === "analysis" ? [] : [SECRET_PATH_POLICY_TOKEN],
1133
+ };
1134
+ return {
1135
+ commands,
1136
+ maxIterations: mode === "analysis" ? 12 : mode === "migration" ? 30 : 25,
1137
+ interIterationDelay: 0,
1138
+ timeout: 300,
1139
+ requiredOutputs: [],
1140
+ guardrails,
465
1141
  };
466
- const maxIterations = mode === "analysis" ? 12 : mode === "migration" ? 30 : 25;
1142
+ }
1143
+
1144
+ function renderDraftBody(task: string, mode: DraftMode, commands: CommandDef[]): string {
1145
+ const commandSections = commands.map((command) => bodySection(command.name === "git-log" ? "Recent git history" : `Latest ${command.name} output`, `{{ commands.${command.name} }}`));
1146
+ return mode === "analysis"
1147
+ ? [
1148
+ `Task: ${escapeHtmlCommentMarkers(task)}`,
1149
+ "",
1150
+ ...commandSections,
1151
+ "",
1152
+ "Start with read-only inspection. Avoid edits and commits until you have a clear plan.",
1153
+ "Map the architecture, identify entry points, and summarize the important moving parts.",
1154
+ "End each iteration with concrete findings, open questions, and the next files to inspect.",
1155
+ "Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
1156
+ ].join("\n")
1157
+ : [
1158
+ `Task: ${escapeHtmlCommentMarkers(task)}`,
1159
+ "",
1160
+ ...commandSections,
1161
+ "",
1162
+ mode === "fix" ? "If tests or lint are failing, fix those failures before starting new work." : "Make the smallest safe change that moves the task forward.",
1163
+ "Prefer concrete, verifiable progress. Explain why your change works.",
1164
+ "Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
1165
+ ].join("\n");
1166
+ }
1167
+
1168
+ function commandIntentsToCommands(commandIntents: CommandIntent[]): CommandDef[] {
1169
+ return commandIntents.map(({ source: _source, ...command }) => command);
1170
+ }
1171
+
1172
+ function renderDraftPlan(task: string, mode: DraftMode, target: DraftTarget, frontmatter: Frontmatter, source: DraftSource, body: string): DraftPlan {
1173
+ const metadata: DraftMetadata = { generator: "pi-ralph-loop", version: 2, source, task, mode };
1174
+ const requiredOutputs = frontmatter.requiredOutputs ?? [];
467
1175
  const frontmatterLines = [
468
- ...renderCommandsYaml(commands),
469
- `max_iterations: ${maxIterations}`,
470
- "timeout: 300",
1176
+ ...renderCommandsYaml(frontmatter.commands),
1177
+ `max_iterations: ${frontmatter.maxIterations}`,
1178
+ `inter_iteration_delay: ${frontmatter.interIterationDelay}`,
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)}`] : []),
471
1184
  "guardrails:",
472
- " block_commands:",
473
- ...guardrails.blockCommands.map((pattern) => ` - ${yamlQuote(pattern)}`),
474
- " protected_files:",
475
- ...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: []"]),
476
1191
  ];
477
1192
 
478
- const commandSections = commands.map((command) => bodySection(command.name === "git-log" ? "Recent git history" : `Latest ${command.name} output`, `{{ commands.${command.name} }}`));
479
- const body =
480
- mode === "analysis"
481
- ? [
482
- `Task: ${escapeHtmlCommentMarkers(task)}`,
483
- "",
484
- ...commandSections,
485
- "",
486
- "Start with read-only inspection. Avoid edits and commits until you have a clear plan.",
487
- "Map the architecture, identify entry points, and summarize the important moving parts.",
488
- "End each iteration with concrete findings, open questions, and the next files to inspect.",
489
- "Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
490
- ].join("\n")
491
- : [
492
- `Task: ${escapeHtmlCommentMarkers(task)}`,
493
- "",
494
- ...commandSections,
495
- "",
496
- mode === "fix"
497
- ? "If tests or lint are failing, fix those failures before starting new work."
498
- : "Make the smallest safe change that moves the task forward.",
499
- "Prefer concrete, verifiable progress. Explain why your change works.",
500
- "Iteration {{ ralph.iteration }} of {{ ralph.name }}.",
501
- ].join("\n");
502
-
503
1193
  return {
504
1194
  task,
505
1195
  mode,
506
1196
  target,
1197
+ source,
507
1198
  content: `${metadataComment(metadata)}\n${yamlBlock(frontmatterLines)}\n\n${body}`,
508
- commandLabels: commands.map(formatCommandLabel),
509
- safetyLabel: summarizeSafetyLabel(guardrails),
510
- finishLabel: summarizeFinishLabel(maxIterations),
1199
+ commandLabels: frontmatter.commands.map(formatCommandLabel),
1200
+ safetyLabel: summarizeSafetyLabel(frontmatter.guardrails),
1201
+ finishLabel: summarizeFinishLabel(frontmatter),
1202
+ };
1203
+ }
1204
+
1205
+ export function generateDraftFromRequest(request: Omit<DraftRequest, "baselineDraft">, source: DraftSource): DraftPlan {
1206
+ const commands = commandIntentsToCommands(request.commandIntent);
1207
+ const frontmatter = buildDraftFrontmatter(request.mode, commands);
1208
+ return renderDraftPlan(request.task, request.mode, request.target, frontmatter, source, renderDraftBody(request.task, request.mode, commands));
1209
+ }
1210
+
1211
+ export function buildDraftRequest(task: string, target: DraftTarget, repoSignals: RepoSignals, repoContext?: RepoContext): DraftRequest {
1212
+ const mode = classifyTaskMode(task);
1213
+ const commandIntents = buildCommandIntent(mode, repoSignals);
1214
+ const request: Omit<DraftRequest, "baselineDraft"> = {
1215
+ task,
1216
+ mode,
1217
+ target,
1218
+ repoSignals,
1219
+ repoContext: normalizeRepoContext(repoContext, repoSignals),
1220
+ commandIntent: commandIntents,
511
1221
  };
1222
+ return { ...request, baselineDraft: generateDraftFromRequest(request, "deterministic").content };
1223
+ }
1224
+
1225
+ export function normalizeStrengthenedDraft(request: DraftRequest, strengthenedDraft: string, scope: DraftStrengtheningScope): DraftPlan {
1226
+ const baseline = parseRalphMarkdown(request.baselineDraft);
1227
+ const strengthened = parseStrictRalphMarkdown(strengthenedDraft);
1228
+
1229
+ if (scope === "body-only") {
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);
1239
+ }
1240
+
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);
1247
+ }
1248
+
1249
+ export function hasFakeRuntimeEnforcementClaim(text: string): boolean {
1250
+ return /read[-\s]?only enforced|write protection is enforced/i.test(text);
1251
+ }
1252
+
1253
+ export function isWeakStrengthenedDraft(baselineBody: string, analysisText: string, strengthenedBody: string): boolean {
1254
+ return baselineBody.trim() === strengthenedBody.trim() || hasFakeRuntimeEnforcementClaim(analysisText) || hasFakeRuntimeEnforcementClaim(strengthenedBody);
1255
+ }
1256
+
1257
+ export function generateDraft(task: string, target: DraftTarget, signals: RepoSignals): DraftPlan {
1258
+ const request = buildDraftRequest(task, target, signals);
1259
+ return generateDraftFromRequest(request, "deterministic");
512
1260
  }
513
1261
 
514
1262
  export function extractDraftMetadata(raw: string): DraftMetadata | undefined {
515
1263
  const match = raw.match(/^<!-- pi-ralph-loop: (.+?) -->/);
516
1264
  if (!match) return undefined;
1265
+
517
1266
  try {
518
- const parsed = JSON.parse(decodeDraftMetadata(match[1])) as DraftMetadata;
519
- return parsed?.generator === "pi-ralph-loop" ? parsed : undefined;
1267
+ const parsed: unknown = JSON.parse(decodeDraftMetadata(match[1]));
1268
+ if (!isRecord(parsed) || parsed.generator !== "pi-ralph-loop") return undefined;
1269
+ if (!isDraftMode(parsed.mode) || typeof parsed.task !== "string") return undefined;
1270
+
1271
+ if (parsed.version === 1) {
1272
+ return { generator: "pi-ralph-loop", version: 1, task: parsed.task, mode: parsed.mode };
1273
+ }
1274
+
1275
+ if (parsed.version === 2 && isDraftSource(parsed.source)) {
1276
+ return { generator: "pi-ralph-loop", version: 2, source: parsed.source, task: parsed.task, mode: parsed.mode };
1277
+ }
1278
+
1279
+ return undefined;
520
1280
  } catch {
521
1281
  return undefined;
522
1282
  }
@@ -534,20 +1294,19 @@ export type DraftContentInspection = {
534
1294
 
535
1295
  export function inspectDraftContent(raw: string): DraftContentInspection {
536
1296
  const metadata = extractDraftMetadata(raw);
537
- const normalized = normalizeRawRalph(raw);
1297
+ const parsed = parseStrictRalphMarkdown(raw);
538
1298
 
539
- if (!hasRalphFrontmatter(normalized)) {
540
- return { metadata, error: "Missing RALPH frontmatter" };
1299
+ if ("error" in parsed) {
1300
+ return { metadata, error: parsed.error };
541
1301
  }
542
1302
 
543
- try {
544
- const parsed = parseRalphMarkdown(normalized);
545
- const error = validateFrontmatter(parsed.frontmatter);
546
- return error ? { metadata, parsed, error } : { metadata, parsed };
547
- } catch (err) {
548
- const message = err instanceof Error ? err.message : String(err);
549
- 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" };
550
1306
  }
1307
+
1308
+ const error = validateFrontmatter(parsed.parsed.frontmatter);
1309
+ return error ? { metadata, parsed: parsed.parsed, error } : { metadata, parsed: parsed.parsed };
551
1310
  }
552
1311
 
553
1312
  export function validateDraftContent(raw: string): string | null {
@@ -576,9 +1335,8 @@ export function buildMissionBrief(plan: DraftPlan): string {
576
1335
  }
577
1336
 
578
1337
  const parsed = inspection.parsed!;
579
- const mode = inspection.metadata?.mode ?? "general";
580
1338
  const commandLabels = parsed.frontmatter.commands.map(formatCommandLabel);
581
- const finishLabel = summarizeFinishLabel(parsed.frontmatter.maxIterations);
1339
+ const finishBehavior = summarizeFinishBehavior(parsed.frontmatter);
582
1340
  const safetyLabel = summarizeSafetyLabel(parsed.frontmatter.guardrails);
583
1341
 
584
1342
  return [
@@ -595,7 +1353,7 @@ export function buildMissionBrief(plan: DraftPlan): string {
595
1353
  ...commandLabels.map((label) => `- ${label}`),
596
1354
  "",
597
1355
  "Finish behavior",
598
- `- ${finishLabel}`,
1356
+ ...finishBehavior,
599
1357
  "",
600
1358
  "Safety",
601
1359
  `- ${safetyLabel}`,
@@ -611,20 +1369,77 @@ export function shouldStopForCompletionPromise(text: string, expected: string):
611
1369
  return extractCompletionPromise(text) === expected.trim();
612
1370
  }
613
1371
 
614
- 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 {
615
1392
  const map = new Map(outputs.map((o) => [o.name, o.output]));
616
- return body
617
- .replace(/\{\{\s*commands\.(\w[\w-]*)\s*\}\}/g, (_, name) => map.get(name) ?? "")
618
- .replace(/\{\{\s*ralph\.iteration\s*\}\}/g, String(ralph.iteration))
619
- .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);
620
1405
  }
621
1406
 
622
- export function renderRalphBody(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
623
- 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, "");
624
1414
  }
625
1415
 
626
- export function renderIterationPrompt(body: string, iteration: number, maxIterations: number): string {
627
- 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")}`;
628
1443
  }
629
1444
 
630
1445
  export function shouldWarnForBashFailure(output: string): boolean {