@lnilluv/pi-ralph-loop 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ralph.ts ADDED
@@ -0,0 +1,642 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
+ import { basename, dirname, join, resolve } from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+
5
+ export type CommandDef = { name: string; run: string; timeout: number };
6
+ export type Frontmatter = {
7
+ commands: CommandDef[];
8
+ maxIterations: number;
9
+ timeout: number;
10
+ completionPromise?: string;
11
+ guardrails: { blockCommands: string[]; protectedFiles: string[] };
12
+ invalidCommandEntries?: number[];
13
+ };
14
+ export type ParsedRalph = { frontmatter: Frontmatter; body: string };
15
+ export type CommandOutput = { name: string; output: string };
16
+ export type RalphTargetResolution = {
17
+ target: string;
18
+ absoluteTarget: string;
19
+ markdownPath: string;
20
+ };
21
+ export type CommandArgs =
22
+ | { mode: "path" | "task"; value: string }
23
+ | { mode: "auto"; value: string };
24
+ export type ExistingTargetInspection =
25
+ | { kind: "run"; ralphPath: string }
26
+ | { kind: "invalid-markdown"; path: string }
27
+ | { kind: "invalid-target"; path: string }
28
+ | { kind: "dir-without-ralph"; dirPath: string; ralphPath: string }
29
+ | { kind: "missing-path"; dirPath: string; ralphPath: string }
30
+ | { kind: "not-path" };
31
+ 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
+ };
38
+ export type DraftTarget = {
39
+ slug: string;
40
+ dirPath: string;
41
+ ralphPath: string;
42
+ };
43
+ export type PlannedTaskTarget =
44
+ | { kind: "draft"; target: DraftTarget }
45
+ | { kind: "conflict"; target: DraftTarget };
46
+ export type RepoSignals = {
47
+ packageManager?: "npm" | "pnpm" | "yarn" | "bun";
48
+ testCommand?: string;
49
+ lintCommand?: string;
50
+ hasGit: boolean;
51
+ topLevelDirs: string[];
52
+ topLevelFiles: string[];
53
+ };
54
+ export type DraftPlan = {
55
+ task: string;
56
+ mode: DraftMode;
57
+ target: DraftTarget;
58
+ content: string;
59
+ commandLabels: string[];
60
+ safetyLabel: string;
61
+ finishLabel: string;
62
+ };
63
+
64
+ type UnknownRecord = Record<string, unknown>;
65
+
66
+ function isRecord(value: unknown): value is UnknownRecord {
67
+ return typeof value === "object" && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ function parseRalphFrontmatter(raw: string): UnknownRecord {
71
+ const parsed: unknown = parseYaml(raw);
72
+ return isRecord(parsed) ? parsed : {};
73
+ }
74
+
75
+ function parseCommandDef(value: unknown): CommandDef | null {
76
+ if (!isRecord(value)) return null;
77
+ return {
78
+ name: String(value.name ?? ""),
79
+ run: String(value.run ?? ""),
80
+ timeout: Number(value.timeout ?? 60),
81
+ };
82
+ }
83
+
84
+ function toUnknownArray(value: unknown): unknown[] {
85
+ return Array.isArray(value) ? value : [];
86
+ }
87
+
88
+ function toStringArray(value: unknown): string[] {
89
+ return Array.isArray(value) ? value.map((item) => String(item)) : [];
90
+ }
91
+
92
+ function normalizeRawRalph(raw: string): string {
93
+ return raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
94
+ }
95
+
96
+ function matchRalphMarkdown(raw: string): RegExpMatchArray | null {
97
+ return normalizeRawRalph(raw).match(/^(?:\s*<!--[\s\S]*?-->\s*)*---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
98
+ }
99
+
100
+ function hasRalphFrontmatter(raw: string): boolean {
101
+ return matchRalphMarkdown(raw) !== null;
102
+ }
103
+
104
+ function normalizeMissingMarkdownTarget(absoluteTarget: string): { dirPath: string; ralphPath: string } {
105
+ if (basename(absoluteTarget) === "RALPH.md") {
106
+ return { dirPath: dirname(absoluteTarget), ralphPath: absoluteTarget };
107
+ }
108
+
109
+ const dirPath = absoluteTarget.slice(0, -3);
110
+ return { dirPath, ralphPath: join(dirPath, "RALPH.md") };
111
+ }
112
+
113
+ function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
114
+ const labels: string[] = [];
115
+ if (guardrails.blockCommands.some((pattern) => pattern.includes("git") && pattern.includes("push"))) {
116
+ labels.push("blocks git push");
117
+ } else if (guardrails.blockCommands.length > 0) {
118
+ labels.push(`blocks ${guardrails.blockCommands.length} command pattern${guardrails.blockCommands.length === 1 ? "" : "s"}`);
119
+ }
120
+ if (guardrails.protectedFiles.some((pattern) => pattern.includes(".env") || pattern.includes("secret"))) {
121
+ labels.push("blocks write/edit to secret files");
122
+ } else if (guardrails.protectedFiles.length > 0) {
123
+ labels.push(`blocks write/edit to ${guardrails.protectedFiles.length} file glob${guardrails.protectedFiles.length === 1 ? "" : "s"}`);
124
+ }
125
+ return labels.length > 0 ? labels.join(" and ") : "No extra safety rules";
126
+ }
127
+
128
+ function summarizeFinishLabel(maxIterations: number): string {
129
+ return `Stop after ${maxIterations} iterations or /ralph-stop`;
130
+ }
131
+
132
+ function isRalphMarkdownPath(path: string): boolean {
133
+ return basename(path) === "RALPH.md";
134
+ }
135
+
136
+ function detectPackageManager(cwd: string): RepoSignals["packageManager"] {
137
+ if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
138
+ if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
139
+ if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
140
+ if (existsSync(join(cwd, "package-lock.json")) || existsSync(join(cwd, "package.json"))) return "npm";
141
+ return undefined;
142
+ }
143
+
144
+ function packageRunCommand(packageManager: RepoSignals["packageManager"], script: string): string {
145
+ if (packageManager === "pnpm") return `pnpm ${script}`;
146
+ if (packageManager === "yarn") return `yarn ${script}`;
147
+ if (packageManager === "bun") return `bun run ${script}`;
148
+ if (script === "test") return "npm test";
149
+ return `npm run ${script}`;
150
+ }
151
+
152
+ function detectPackageScripts(cwd: string, packageManager: RepoSignals["packageManager"]): Pick<RepoSignals, "testCommand" | "lintCommand"> {
153
+ const packageJsonPath = join(cwd, "package.json");
154
+ if (!existsSync(packageJsonPath)) return {};
155
+
156
+ try {
157
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { scripts?: Record<string, unknown> };
158
+ const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {};
159
+ const testValue = typeof scripts.test === "string" ? scripts.test : undefined;
160
+ const lintValue = typeof scripts.lint === "string" ? scripts.lint : undefined;
161
+
162
+ const testCommand = testValue && !/no test specified/i.test(testValue) ? packageRunCommand(packageManager, "test") : undefined;
163
+ const lintCommand = lintValue ? packageRunCommand(packageManager, "lint") : undefined;
164
+ return { testCommand, lintCommand };
165
+ } catch {
166
+ return {};
167
+ }
168
+ }
169
+
170
+ function encodeDraftMetadata(metadata: DraftMetadata): string {
171
+ return encodeURIComponent(JSON.stringify(metadata));
172
+ }
173
+
174
+ function decodeDraftMetadata(value: string): string {
175
+ try {
176
+ return decodeURIComponent(value);
177
+ } catch {
178
+ return value;
179
+ }
180
+ }
181
+
182
+ function metadataComment(metadata: DraftMetadata): string {
183
+ return `<!-- pi-ralph-loop: ${encodeDraftMetadata(metadata)} -->`;
184
+ }
185
+
186
+ function yamlBlock(lines: string[]): string {
187
+ return `---\n${lines.join("\n")}\n---`;
188
+ }
189
+
190
+ function yamlQuote(value: string): string {
191
+ return `'${value.replace(/'/g, "''")}'`;
192
+ }
193
+
194
+ function renderCommandsYaml(commands: CommandDef[]): string[] {
195
+ if (commands.length === 0) return ["commands: []"];
196
+ return [
197
+ "commands:",
198
+ ...commands.flatMap((command) => [
199
+ ` - name: ${command.name}`,
200
+ ` run: ${command.run}`,
201
+ ` timeout: ${command.timeout}`,
202
+ ]),
203
+ ];
204
+ }
205
+
206
+ function bodySection(title: string, placeholder: string): string {
207
+ return `${title}:\n${placeholder}`;
208
+ }
209
+
210
+ function escapeHtmlCommentMarkers(text: string): string {
211
+ return text.replace(/<!--/g, "&lt;!--").replace(/-->/g, "--&gt;");
212
+ }
213
+
214
+ export function defaultFrontmatter(): Frontmatter {
215
+ return { commands: [], maxIterations: 50, timeout: 300, guardrails: { blockCommands: [], protectedFiles: [] } };
216
+ }
217
+
218
+ export function parseRalphMarkdown(raw: string): ParsedRalph {
219
+ const normalized = normalizeRawRalph(raw);
220
+ const match = matchRalphMarkdown(normalized);
221
+ if (!match) return { frontmatter: defaultFrontmatter(), body: normalized };
222
+
223
+ const yaml = parseRalphFrontmatter(match[1]);
224
+ const invalidCommandEntries: number[] = [];
225
+ const commands = toUnknownArray(yaml.commands).flatMap((command, index) => {
226
+ const parsed = parseCommandDef(command);
227
+ if (!parsed) {
228
+ invalidCommandEntries.push(index);
229
+ return [];
230
+ }
231
+ return [parsed];
232
+ });
233
+ const guardrails = isRecord(yaml.guardrails) ? yaml.guardrails : {};
234
+
235
+ return {
236
+ frontmatter: {
237
+ commands,
238
+ maxIterations: Number(yaml.max_iterations ?? 50),
239
+ timeout: Number(yaml.timeout ?? 300),
240
+ completionPromise:
241
+ typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
242
+ guardrails: {
243
+ blockCommands: toStringArray(guardrails.block_commands),
244
+ protectedFiles: toStringArray(guardrails.protected_files),
245
+ },
246
+ invalidCommandEntries: invalidCommandEntries.length > 0 ? invalidCommandEntries : undefined,
247
+ },
248
+ body: match[2] ?? "",
249
+ };
250
+ }
251
+
252
+ export function validateFrontmatter(fm: Frontmatter): string | null {
253
+ if ((fm.invalidCommandEntries?.length ?? 0) > 0) {
254
+ return `Invalid command entry at index ${fm.invalidCommandEntries![0]}`;
255
+ }
256
+ if (!Number.isFinite(fm.maxIterations) || !Number.isInteger(fm.maxIterations) || fm.maxIterations <= 0) {
257
+ return "Invalid max_iterations: must be a positive finite integer";
258
+ }
259
+ if (!Number.isFinite(fm.timeout) || fm.timeout <= 0) {
260
+ return "Invalid timeout: must be a positive finite number";
261
+ }
262
+ for (const pattern of fm.guardrails.blockCommands) {
263
+ try {
264
+ new RegExp(pattern);
265
+ } catch {
266
+ return `Invalid block_commands regex: ${pattern}`;
267
+ }
268
+ }
269
+ for (const cmd of fm.commands) {
270
+ if (!cmd.name.trim()) {
271
+ return "Invalid command: name is required";
272
+ }
273
+ if (!cmd.run.trim()) {
274
+ return `Invalid command ${cmd.name}: run is required`;
275
+ }
276
+ if (!Number.isFinite(cmd.timeout) || cmd.timeout <= 0) {
277
+ return `Invalid command ${cmd.name}: timeout must be positive`;
278
+ }
279
+ }
280
+ return null;
281
+ }
282
+
283
+ export function findBlockedCommandPattern(command: string, blockPatterns: string[]): string | undefined {
284
+ for (const pattern of blockPatterns) {
285
+ try {
286
+ if (new RegExp(pattern).test(command)) return pattern;
287
+ } catch {
288
+ // ignore malformed regexes; validateFrontmatter should catch these first
289
+ }
290
+ }
291
+ return undefined;
292
+ }
293
+
294
+ 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 };
301
+ }
302
+
303
+ export function looksLikePath(value: string): boolean {
304
+ const trimmed = value.trim();
305
+ if (!trimmed) return false;
306
+ if (/\s/.test(trimmed)) return false;
307
+ return (
308
+ trimmed.startsWith(".") ||
309
+ trimmed.startsWith("/") ||
310
+ trimmed.includes("\\") ||
311
+ trimmed.includes("/") ||
312
+ trimmed.endsWith(".md") ||
313
+ trimmed.includes("-")
314
+ );
315
+ }
316
+
317
+ export function resolveRalphTarget(args: string): string {
318
+ return args.trim() || ".";
319
+ }
320
+
321
+ export function resolveRalphTargetResolution(args: string, cwd: string): RalphTargetResolution {
322
+ const target = resolveRalphTarget(args);
323
+ const absoluteTarget = resolve(cwd, target);
324
+ return {
325
+ target,
326
+ absoluteTarget,
327
+ markdownPath: absoluteTarget.endsWith(".md") ? absoluteTarget : join(absoluteTarget, "RALPH.md"),
328
+ };
329
+ }
330
+
331
+ export function inspectExistingTarget(input: string, cwd: string, explicitPath = false): ExistingTargetInspection {
332
+ const resolution = resolveRalphTargetResolution(input, cwd);
333
+ const absoluteTarget = resolution.absoluteTarget;
334
+ const markdownPath = resolution.markdownPath;
335
+
336
+ if (existsSync(absoluteTarget)) {
337
+ const stats = statSync(absoluteTarget);
338
+ if (stats.isDirectory()) {
339
+ return existsSync(markdownPath)
340
+ ? { kind: "run", ralphPath: markdownPath }
341
+ : { kind: "dir-without-ralph", dirPath: absoluteTarget, ralphPath: markdownPath };
342
+ }
343
+ if (isRalphMarkdownPath(absoluteTarget)) {
344
+ return { kind: "run", ralphPath: absoluteTarget };
345
+ }
346
+ if (absoluteTarget.endsWith(".md")) {
347
+ return { kind: "invalid-markdown", path: absoluteTarget };
348
+ }
349
+ return { kind: "invalid-target", path: absoluteTarget };
350
+ }
351
+
352
+ if (!explicitPath && !looksLikePath(input)) {
353
+ return { kind: "not-path" };
354
+ }
355
+
356
+ if (absoluteTarget.endsWith(".md")) {
357
+ return { kind: "missing-path", ...normalizeMissingMarkdownTarget(absoluteTarget) };
358
+ }
359
+
360
+ return { kind: "missing-path", dirPath: absoluteTarget, ralphPath: markdownPath };
361
+ }
362
+
363
+ export function slugifyTask(task: string): string {
364
+ const slug = task
365
+ .toLowerCase()
366
+ .replace(/[^a-z0-9]+/g, "-")
367
+ .replace(/^-+|-+$/g, "")
368
+ .slice(0, 80)
369
+ .replace(/^-+|-+$/g, "");
370
+ return slug || "ralph-task";
371
+ }
372
+
373
+ export function nextSiblingSlug(baseSlug: string, hasRalphAtSlug: (slug: string) => boolean): string {
374
+ let suffix = 2;
375
+ let next = `${baseSlug}-${suffix}`;
376
+ while (hasRalphAtSlug(next)) {
377
+ suffix += 1;
378
+ next = `${baseSlug}-${suffix}`;
379
+ }
380
+ return next;
381
+ }
382
+
383
+ export function classifyTaskMode(task: string): DraftMode {
384
+ const normalized = task.toLowerCase();
385
+ if (/(reverse engineer|analy[sz]e|understand|investigate|map|audit|explore)/.test(normalized)) return "analysis";
386
+ if (/(fix|debug|repair|failing test|flaky|failure|broken)/.test(normalized)) return "fix";
387
+ if (/(migrate|upgrade|convert|port|modernize)/.test(normalized)) return "migration";
388
+ return "general";
389
+ }
390
+
391
+ export function planTaskDraftTarget(cwd: string, task: string): PlannedTaskTarget {
392
+ const slug = slugifyTask(task);
393
+ const target: DraftTarget = {
394
+ slug,
395
+ dirPath: join(cwd, slug),
396
+ ralphPath: join(cwd, slug, "RALPH.md"),
397
+ };
398
+ return existsSync(target.dirPath) ? { kind: "conflict", target } : { kind: "draft", target };
399
+ }
400
+
401
+ export function createSiblingTarget(cwd: string, baseSlug: string): DraftTarget {
402
+ const siblingSlug = nextSiblingSlug(baseSlug, (candidate) => existsSync(join(cwd, candidate)));
403
+ return {
404
+ slug: siblingSlug,
405
+ dirPath: join(cwd, siblingSlug),
406
+ ralphPath: join(cwd, siblingSlug, "RALPH.md"),
407
+ };
408
+ }
409
+
410
+ export function inspectRepo(cwd: string): RepoSignals {
411
+ const packageManager = detectPackageManager(cwd);
412
+ const packageScripts = detectPackageScripts(cwd, packageManager);
413
+ let topLevelDirs: string[] = [];
414
+ let topLevelFiles: string[] = [];
415
+
416
+ try {
417
+ 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);
420
+ } catch {
421
+ // ignore bounded inspection failures
422
+ }
423
+
424
+ return {
425
+ packageManager,
426
+ testCommand: packageScripts.testCommand,
427
+ lintCommand: packageScripts.lintCommand,
428
+ hasGit: existsSync(join(cwd, ".git")),
429
+ topLevelDirs,
430
+ topLevelFiles,
431
+ };
432
+ }
433
+
434
+ export function suggestedCommandsForMode(mode: DraftMode, signals: RepoSignals): CommandDef[] {
435
+ 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 });
438
+ return commands;
439
+ }
440
+
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 });
446
+ return commands;
447
+ }
448
+
449
+ function formatCommandLabel(command: CommandDef): string {
450
+ return `${command.name}: ${command.run}`;
451
+ }
452
+
453
+ function extractVisibleTask(body: string): string | undefined {
454
+ const match = body.match(/^Task:\s*(.+)$/m);
455
+ return match?.[1]?.trim() || undefined;
456
+ }
457
+
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 };
462
+ const guardrails = {
463
+ blockCommands: ["git\\s+push"],
464
+ protectedFiles: mode === "analysis" ? [] : [".env*", "**/secrets/**"],
465
+ };
466
+ const maxIterations = mode === "analysis" ? 12 : mode === "migration" ? 30 : 25;
467
+ const frontmatterLines = [
468
+ ...renderCommandsYaml(commands),
469
+ `max_iterations: ${maxIterations}`,
470
+ "timeout: 300",
471
+ "guardrails:",
472
+ " block_commands:",
473
+ ...guardrails.blockCommands.map((pattern) => ` - ${yamlQuote(pattern)}`),
474
+ " protected_files:",
475
+ ...guardrails.protectedFiles.map((pattern) => ` - ${yamlQuote(pattern)}`),
476
+ ];
477
+
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
+ return {
504
+ task,
505
+ mode,
506
+ target,
507
+ content: `${metadataComment(metadata)}\n${yamlBlock(frontmatterLines)}\n\n${body}`,
508
+ commandLabels: commands.map(formatCommandLabel),
509
+ safetyLabel: summarizeSafetyLabel(guardrails),
510
+ finishLabel: summarizeFinishLabel(maxIterations),
511
+ };
512
+ }
513
+
514
+ export function extractDraftMetadata(raw: string): DraftMetadata | undefined {
515
+ const match = raw.match(/^<!-- pi-ralph-loop: (.+?) -->/);
516
+ if (!match) return undefined;
517
+ try {
518
+ const parsed = JSON.parse(decodeDraftMetadata(match[1])) as DraftMetadata;
519
+ return parsed?.generator === "pi-ralph-loop" ? parsed : undefined;
520
+ } catch {
521
+ return undefined;
522
+ }
523
+ }
524
+
525
+ export function shouldValidateExistingDraft(raw: string): boolean {
526
+ return extractDraftMetadata(raw) !== undefined;
527
+ }
528
+
529
+ export type DraftContentInspection = {
530
+ metadata?: DraftMetadata;
531
+ parsed?: ParsedRalph;
532
+ error?: string;
533
+ };
534
+
535
+ export function inspectDraftContent(raw: string): DraftContentInspection {
536
+ const metadata = extractDraftMetadata(raw);
537
+ const normalized = normalizeRawRalph(raw);
538
+
539
+ if (!hasRalphFrontmatter(normalized)) {
540
+ return { metadata, error: "Missing RALPH frontmatter" };
541
+ }
542
+
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}` };
550
+ }
551
+ }
552
+
553
+ export function validateDraftContent(raw: string): string | null {
554
+ return inspectDraftContent(raw).error ?? null;
555
+ }
556
+
557
+ export function buildMissionBrief(plan: DraftPlan): string {
558
+ const inspection = inspectDraftContent(plan.content);
559
+ const task = extractVisibleTask(inspection.parsed?.body ?? "") ?? inspection.metadata?.task ?? "Task metadata missing from current draft";
560
+
561
+ if (inspection.error) {
562
+ return [
563
+ "Mission Brief",
564
+ "Review what Ralph will do before it starts.",
565
+ "",
566
+ "Task",
567
+ task,
568
+ "",
569
+ "File",
570
+ plan.target.ralphPath,
571
+ "",
572
+ "Draft status",
573
+ `- Invalid RALPH.md: ${inspection.error}`,
574
+ "- Reopen RALPH.md to fix it or cancel",
575
+ ].join("\n");
576
+ }
577
+
578
+ const parsed = inspection.parsed!;
579
+ const mode = inspection.metadata?.mode ?? "general";
580
+ const commandLabels = parsed.frontmatter.commands.map(formatCommandLabel);
581
+ const finishLabel = summarizeFinishLabel(parsed.frontmatter.maxIterations);
582
+ const safetyLabel = summarizeSafetyLabel(parsed.frontmatter.guardrails);
583
+
584
+ return [
585
+ "Mission Brief",
586
+ "Review what Ralph will do before it starts.",
587
+ "",
588
+ "Task",
589
+ task,
590
+ "",
591
+ "File",
592
+ plan.target.ralphPath,
593
+ "",
594
+ "Suggested checks",
595
+ ...commandLabels.map((label) => `- ${label}`),
596
+ "",
597
+ "Finish behavior",
598
+ `- ${finishLabel}`,
599
+ "",
600
+ "Safety",
601
+ `- ${safetyLabel}`,
602
+ ].join("\n");
603
+ }
604
+
605
+ export function extractCompletionPromise(text: string): string | undefined {
606
+ const match = text.match(/<promise>([^<]+)<\/promise>/);
607
+ return match?.[1]?.trim() || undefined;
608
+ }
609
+
610
+ export function shouldStopForCompletionPromise(text: string, expected: string): boolean {
611
+ return extractCompletionPromise(text) === expected.trim();
612
+ }
613
+
614
+ export function resolvePlaceholders(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
615
+ 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);
620
+ }
621
+
622
+ export function renderRalphBody(body: string, outputs: CommandOutput[], ralph: { iteration: number; name: string }): string {
623
+ return resolvePlaceholders(body, outputs, ralph).replace(/<!--[\s\S]*?-->/g, "");
624
+ }
625
+
626
+ export function renderIterationPrompt(body: string, iteration: number, maxIterations: number): string {
627
+ return `[ralph: iteration ${iteration}/${maxIterations}]\n\n${body}`;
628
+ }
629
+
630
+ export function shouldWarnForBashFailure(output: string): boolean {
631
+ return /FAIL|ERROR|error:|failed/i.test(output);
632
+ }
633
+
634
+ export function classifyIdleState(timedOut: boolean, idleError?: Error): "ok" | "timeout" | "error" {
635
+ if (timedOut) return "timeout";
636
+ if (idleError) return "error";
637
+ return "ok";
638
+ }
639
+
640
+ export function shouldResetFailCount(previousSessionFile?: string, nextSessionFile?: string): boolean {
641
+ return Boolean(previousSessionFile && nextSessionFile && previousSessionFile !== nextSessionFile);
642
+ }