@lnilluv/pi-ralph-loop 0.1.3 → 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.
- package/.github/workflows/ci.yml +5 -2
- package/.github/workflows/release.yml +7 -4
- package/README.md +151 -15
- package/package.json +13 -4
- package/src/index.ts +1419 -176
- package/src/ralph-draft-context.ts +618 -0
- package/src/ralph-draft-llm.ts +297 -0
- package/src/ralph-draft.ts +33 -0
- package/src/ralph.ts +1457 -0
- package/src/runner-rpc.ts +434 -0
- package/src/runner-state.ts +822 -0
- package/src/runner.ts +957 -0
- package/src/secret-paths.ts +66 -0
- package/src/shims.d.ts +23 -0
- package/tests/fixtures/parity/migrate/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/migrate/RALPH.md +27 -0
- package/tests/fixtures/parity/migrate/golden/MIGRATED.md +15 -0
- package/tests/fixtures/parity/migrate/legacy/source.md +6 -0
- package/tests/fixtures/parity/migrate/legacy/source.yaml +3 -0
- package/tests/fixtures/parity/migrate/scripts/show-legacy.sh +10 -0
- package/tests/fixtures/parity/migrate/scripts/verify.sh +15 -0
- package/tests/fixtures/parity/research/OPEN_QUESTIONS.md +3 -0
- package/tests/fixtures/parity/research/RALPH.md +45 -0
- package/tests/fixtures/parity/research/claim-evidence-checklist.md +15 -0
- package/tests/fixtures/parity/research/expected-outputs.md +22 -0
- package/tests/fixtures/parity/research/scripts/show-snapshots.sh +13 -0
- package/tests/fixtures/parity/research/scripts/verify.sh +55 -0
- package/tests/fixtures/parity/research/snapshots/app-factory-ai-cli.md +11 -0
- package/tests/fixtures/parity/research/snapshots/docs-factory-ai-cli-features-missions.md +11 -0
- package/tests/fixtures/parity/research/snapshots/factory-ai-news-missions.md +11 -0
- package/tests/fixtures/parity/research/source-manifest.md +20 -0
- package/tests/index.test.ts +3529 -0
- package/tests/parity/README.md +9 -0
- package/tests/parity/harness.py +526 -0
- package/tests/parity-harness.test.ts +42 -0
- package/tests/parity-research-fixture.test.ts +34 -0
- package/tests/ralph-draft-context.test.ts +672 -0
- package/tests/ralph-draft-llm.test.ts +434 -0
- package/tests/ralph-draft.test.ts +168 -0
- package/tests/ralph.test.ts +1840 -0
- package/tests/runner-event-contract.test.ts +235 -0
- package/tests/runner-rpc.test.ts +358 -0
- package/tests/runner-state.test.ts +553 -0
- package/tests/runner.test.ts +1347 -0
- package/tests/secret-paths.test.ts +55 -0
- package/tsconfig.json +3 -2
package/src/ralph.ts
ADDED
|
@@ -0,0 +1,1457 @@
|
|
|
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
|
+
import { SECRET_PATH_POLICY_TOKEN, filterSecretBearingTopLevelNames, isSecretBearingPath, isSecretBearingTopLevelName } from "./secret-paths.ts";
|
|
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>;
|
|
12
|
+
export type Frontmatter = {
|
|
13
|
+
commands: CommandDef[];
|
|
14
|
+
args?: string[];
|
|
15
|
+
maxIterations: number;
|
|
16
|
+
interIterationDelay: number;
|
|
17
|
+
timeout: number;
|
|
18
|
+
completionPromise?: string;
|
|
19
|
+
requiredOutputs?: string[];
|
|
20
|
+
guardrails: { blockCommands: string[]; protectedFiles: string[] };
|
|
21
|
+
invalidCommandEntries?: number[];
|
|
22
|
+
invalidArgEntries?: number[];
|
|
23
|
+
};
|
|
24
|
+
export type ParsedRalph = { frontmatter: Frontmatter; body: string };
|
|
25
|
+
export type CommandOutput = { name: string; output: string };
|
|
26
|
+
export type RalphTargetResolution = {
|
|
27
|
+
target: string;
|
|
28
|
+
absoluteTarget: string;
|
|
29
|
+
markdownPath: string;
|
|
30
|
+
};
|
|
31
|
+
export type CommandArgs = {
|
|
32
|
+
mode: "path" | "task" | "auto";
|
|
33
|
+
value: string;
|
|
34
|
+
runtimeArgs: RuntimeArg[];
|
|
35
|
+
error?: string;
|
|
36
|
+
};
|
|
37
|
+
export type ExistingTargetInspection =
|
|
38
|
+
| { kind: "run"; ralphPath: string }
|
|
39
|
+
| { kind: "invalid-markdown"; path: string }
|
|
40
|
+
| { kind: "invalid-target"; path: string }
|
|
41
|
+
| { kind: "dir-without-ralph"; dirPath: string; ralphPath: string }
|
|
42
|
+
| { kind: "missing-path"; dirPath: string; ralphPath: string }
|
|
43
|
+
| { kind: "not-path" };
|
|
44
|
+
export type DraftMode = "analysis" | "fix" | "migration" | "general";
|
|
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
|
+
};
|
|
59
|
+
export type DraftTarget = {
|
|
60
|
+
slug: string;
|
|
61
|
+
dirPath: string;
|
|
62
|
+
ralphPath: string;
|
|
63
|
+
};
|
|
64
|
+
export type PlannedTaskTarget =
|
|
65
|
+
| { kind: "draft"; target: DraftTarget }
|
|
66
|
+
| { kind: "conflict"; target: DraftTarget };
|
|
67
|
+
export type RepoSignals = {
|
|
68
|
+
packageManager?: "npm" | "pnpm" | "yarn" | "bun";
|
|
69
|
+
testCommand?: string;
|
|
70
|
+
lintCommand?: string;
|
|
71
|
+
hasGit: boolean;
|
|
72
|
+
topLevelDirs: string[];
|
|
73
|
+
topLevelFiles: string[];
|
|
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
|
+
};
|
|
93
|
+
export type DraftPlan = {
|
|
94
|
+
task: string;
|
|
95
|
+
mode: DraftMode;
|
|
96
|
+
target: DraftTarget;
|
|
97
|
+
source: DraftSource;
|
|
98
|
+
content: string;
|
|
99
|
+
commandLabels: string[];
|
|
100
|
+
safetyLabel: string;
|
|
101
|
+
finishLabel: string;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
type UnknownRecord = Record<string, unknown>;
|
|
105
|
+
|
|
106
|
+
function isRecord(value: unknown): value is UnknownRecord {
|
|
107
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
108
|
+
}
|
|
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
|
+
|
|
121
|
+
function parseRalphFrontmatter(raw: string): UnknownRecord {
|
|
122
|
+
const parsed: unknown = parseYaml(raw);
|
|
123
|
+
return isRecord(parsed) ? parsed : {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function parseCommandDef(value: unknown): CommandDef | null {
|
|
127
|
+
if (!isRecord(value)) return null;
|
|
128
|
+
return {
|
|
129
|
+
name: String(value.name ?? ""),
|
|
130
|
+
run: String(value.run ?? ""),
|
|
131
|
+
timeout: Number(value.timeout ?? 60),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function toUnknownArray(value: unknown): unknown[] {
|
|
136
|
+
return Array.isArray(value) ? value : [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function toStringArray(value: unknown): string[] {
|
|
140
|
+
return Array.isArray(value) ? value.map((item) => String(item)) : [];
|
|
141
|
+
}
|
|
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
|
+
|
|
165
|
+
function normalizeRawRalph(raw: string): string {
|
|
166
|
+
return raw.replace(/^\uFEFF/, "").replace(/\r\n/g, "\n");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function matchRalphMarkdown(raw: string): RegExpMatchArray | null {
|
|
170
|
+
return normalizeRawRalph(raw).match(/^(?:\s*<!--[\s\S]*?-->\s*)*---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
171
|
+
}
|
|
172
|
+
|
|
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 };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeMissingMarkdownTarget(absoluteTarget: string): { dirPath: string; ralphPath: string } {
|
|
329
|
+
if (basename(absoluteTarget) === "RALPH.md") {
|
|
330
|
+
return { dirPath: dirname(absoluteTarget), ralphPath: absoluteTarget };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const dirPath = absoluteTarget.slice(0, -3);
|
|
334
|
+
return { dirPath, ralphPath: join(dirPath, "RALPH.md") };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function summarizeSafetyLabel(guardrails: Frontmatter["guardrails"]): string {
|
|
338
|
+
const labels: string[] = [];
|
|
339
|
+
if (guardrails.blockCommands.some((pattern) => pattern.includes("git") && pattern.includes("push"))) {
|
|
340
|
+
labels.push("blocks git push");
|
|
341
|
+
} else if (guardrails.blockCommands.length > 0) {
|
|
342
|
+
labels.push(`blocks ${guardrails.blockCommands.length} command pattern${guardrails.blockCommands.length === 1 ? "" : "s"}`);
|
|
343
|
+
}
|
|
344
|
+
if (guardrails.protectedFiles.some((pattern) => pattern === SECRET_PATH_POLICY_TOKEN || isSecretBearingPath(pattern))) {
|
|
345
|
+
labels.push("blocks write/edit to secret files");
|
|
346
|
+
} else if (guardrails.protectedFiles.length > 0) {
|
|
347
|
+
labels.push(`blocks write/edit to ${guardrails.protectedFiles.length} file glob${guardrails.protectedFiles.length === 1 ? "" : "s"}`);
|
|
348
|
+
}
|
|
349
|
+
return labels.length > 0 ? labels.join(" and ") : "No extra safety rules";
|
|
350
|
+
}
|
|
351
|
+
|
|
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;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function isRalphMarkdownPath(path: string): boolean {
|
|
404
|
+
return basename(path) === "RALPH.md";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function detectPackageManager(cwd: string): RepoSignals["packageManager"] {
|
|
408
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
409
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
410
|
+
if (existsSync(join(cwd, "bun.lockb")) || existsSync(join(cwd, "bun.lock"))) return "bun";
|
|
411
|
+
if (existsSync(join(cwd, "package-lock.json")) || existsSync(join(cwd, "package.json"))) return "npm";
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function packageRunCommand(packageManager: RepoSignals["packageManager"], script: string): string {
|
|
416
|
+
if (packageManager === "pnpm") return `pnpm ${script}`;
|
|
417
|
+
if (packageManager === "yarn") return `yarn ${script}`;
|
|
418
|
+
if (packageManager === "bun") return `bun run ${script}`;
|
|
419
|
+
if (script === "test") return "npm test";
|
|
420
|
+
return `npm run ${script}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function detectPackageScripts(cwd: string, packageManager: RepoSignals["packageManager"]): Pick<RepoSignals, "testCommand" | "lintCommand"> {
|
|
424
|
+
const packageJsonPath = join(cwd, "package.json");
|
|
425
|
+
if (!existsSync(packageJsonPath)) return {};
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { scripts?: Record<string, unknown> };
|
|
429
|
+
const scripts = isRecord(packageJson.scripts) ? packageJson.scripts : {};
|
|
430
|
+
const testValue = typeof scripts.test === "string" ? scripts.test : undefined;
|
|
431
|
+
const lintValue = typeof scripts.lint === "string" ? scripts.lint : undefined;
|
|
432
|
+
|
|
433
|
+
const testCommand = testValue && !/no test specified/i.test(testValue) ? packageRunCommand(packageManager, "test") : undefined;
|
|
434
|
+
const lintCommand = lintValue ? packageRunCommand(packageManager, "lint") : undefined;
|
|
435
|
+
return { testCommand, lintCommand };
|
|
436
|
+
} catch {
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function encodeDraftMetadata(metadata: DraftMetadata): string {
|
|
442
|
+
return encodeURIComponent(JSON.stringify(metadata));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function decodeDraftMetadata(value: string): string {
|
|
446
|
+
try {
|
|
447
|
+
return decodeURIComponent(value);
|
|
448
|
+
} catch {
|
|
449
|
+
return value;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function metadataComment(metadata: DraftMetadata): string {
|
|
454
|
+
return `<!-- pi-ralph-loop: ${encodeDraftMetadata(metadata)} -->`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function yamlBlock(lines: string[]): string {
|
|
458
|
+
return `---\n${lines.join("\n")}\n---`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function yamlQuote(value: string): string {
|
|
462
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function renderCommandsYaml(commands: CommandDef[]): string[] {
|
|
466
|
+
if (commands.length === 0) return ["commands: []"];
|
|
467
|
+
return [
|
|
468
|
+
"commands:",
|
|
469
|
+
...commands.flatMap((command) => [
|
|
470
|
+
` - name: ${command.name}`,
|
|
471
|
+
` run: ${command.run}`,
|
|
472
|
+
` timeout: ${command.timeout}`,
|
|
473
|
+
]),
|
|
474
|
+
];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function bodySection(title: string, placeholder: string): string {
|
|
478
|
+
return `${title}:\n${placeholder}`;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function escapeHtmlCommentMarkers(text: string): string {
|
|
482
|
+
return text.replace(/<!--/g, "<!--").replace(/-->/g, "-->");
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function defaultFrontmatter(): Frontmatter {
|
|
486
|
+
return { commands: [], maxIterations: 50, interIterationDelay: 0, timeout: 300, requiredOutputs: [], guardrails: { blockCommands: [], protectedFiles: [] } };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export function parseRalphMarkdown(raw: string): ParsedRalph {
|
|
490
|
+
const normalized = normalizeRawRalph(raw);
|
|
491
|
+
const match = matchRalphMarkdown(normalized);
|
|
492
|
+
if (!match) return { frontmatter: defaultFrontmatter(), body: normalized };
|
|
493
|
+
|
|
494
|
+
const yaml = parseRalphFrontmatter(match[1]);
|
|
495
|
+
const invalidCommandEntries: number[] = [];
|
|
496
|
+
const commands = toUnknownArray(yaml.commands).flatMap((command, index) => {
|
|
497
|
+
const parsed = parseCommandDef(command);
|
|
498
|
+
if (!parsed) {
|
|
499
|
+
invalidCommandEntries.push(index);
|
|
500
|
+
return [];
|
|
501
|
+
}
|
|
502
|
+
return [parsed];
|
|
503
|
+
});
|
|
504
|
+
const parsedArgs = parseStringArray(yaml.args);
|
|
505
|
+
const guardrails = isRecord(yaml.guardrails) ? yaml.guardrails : {};
|
|
506
|
+
|
|
507
|
+
return {
|
|
508
|
+
frontmatter: {
|
|
509
|
+
commands,
|
|
510
|
+
...(parsedArgs.values.length > 0 ? { args: parsedArgs.values } : {}),
|
|
511
|
+
maxIterations: Number(yaml.max_iterations ?? 50),
|
|
512
|
+
interIterationDelay: Number(yaml.inter_iteration_delay ?? 0),
|
|
513
|
+
timeout: Number(yaml.timeout ?? 300),
|
|
514
|
+
completionPromise:
|
|
515
|
+
typeof yaml.completion_promise === "string" && yaml.completion_promise.trim() ? yaml.completion_promise : undefined,
|
|
516
|
+
requiredOutputs: toStringArray(yaml.required_outputs),
|
|
517
|
+
guardrails: {
|
|
518
|
+
blockCommands: toStringArray(guardrails.block_commands),
|
|
519
|
+
protectedFiles: toStringArray(guardrails.protected_files),
|
|
520
|
+
},
|
|
521
|
+
invalidCommandEntries: invalidCommandEntries.length > 0 ? invalidCommandEntries : undefined,
|
|
522
|
+
...(parsedArgs.invalidEntries ? { invalidArgEntries: parsedArgs.invalidEntries } : {}),
|
|
523
|
+
},
|
|
524
|
+
body: match[2] ?? "",
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function validateFrontmatter(fm: Frontmatter): string | null {
|
|
529
|
+
if ((fm.invalidCommandEntries?.length ?? 0) > 0) {
|
|
530
|
+
return `Invalid command entry at index ${fm.invalidCommandEntries![0]}`;
|
|
531
|
+
}
|
|
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";
|
|
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
|
+
}
|
|
566
|
+
}
|
|
567
|
+
for (const pattern of fm.guardrails.blockCommands) {
|
|
568
|
+
try {
|
|
569
|
+
new RegExp(pattern);
|
|
570
|
+
} catch {
|
|
571
|
+
return `Invalid block_commands regex: ${pattern}`;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (const pattern of fm.guardrails.protectedFiles) {
|
|
575
|
+
if (isUniversalProtectedGlob(pattern)) {
|
|
576
|
+
return `Invalid protected_files glob: ${pattern}`;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
for (const cmd of fm.commands) {
|
|
580
|
+
if (!cmd.name.trim()) {
|
|
581
|
+
return "Invalid command: name is required";
|
|
582
|
+
}
|
|
583
|
+
if (!/^\w[\w-]*$/.test(cmd.name)) {
|
|
584
|
+
return `Invalid command name: ${cmd.name} must match ^\\w[\\w-]*$`;
|
|
585
|
+
}
|
|
586
|
+
if (!cmd.run.trim()) {
|
|
587
|
+
return `Invalid command ${cmd.name}: run is required`;
|
|
588
|
+
}
|
|
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`;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
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
|
+
|
|
686
|
+
export function findBlockedCommandPattern(command: string, blockPatterns: string[]): string | undefined {
|
|
687
|
+
for (const pattern of blockPatterns) {
|
|
688
|
+
try {
|
|
689
|
+
if (new RegExp(pattern).test(command)) return pattern;
|
|
690
|
+
} catch {
|
|
691
|
+
// ignore malformed regexes; validateFrontmatter should catch these first
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
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
|
+
|
|
828
|
+
export function parseCommandArgs(raw: string): CommandArgs {
|
|
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;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
export function looksLikePath(value: string): boolean {
|
|
925
|
+
const trimmed = value.trim();
|
|
926
|
+
if (!trimmed) return false;
|
|
927
|
+
if (/\s/.test(trimmed)) return false;
|
|
928
|
+
return (
|
|
929
|
+
trimmed.startsWith(".") ||
|
|
930
|
+
trimmed.startsWith("/") ||
|
|
931
|
+
trimmed.includes("\\") ||
|
|
932
|
+
trimmed.includes("/") ||
|
|
933
|
+
trimmed.endsWith(".md") ||
|
|
934
|
+
trimmed.includes("-")
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
export function resolveRalphTarget(args: string): string {
|
|
939
|
+
return args.trim() || ".";
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
export function resolveRalphTargetResolution(args: string, cwd: string): RalphTargetResolution {
|
|
943
|
+
const target = resolveRalphTarget(args);
|
|
944
|
+
const absoluteTarget = resolve(cwd, target);
|
|
945
|
+
return {
|
|
946
|
+
target,
|
|
947
|
+
absoluteTarget,
|
|
948
|
+
markdownPath: absoluteTarget.endsWith(".md") ? absoluteTarget : join(absoluteTarget, "RALPH.md"),
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
export function inspectExistingTarget(input: string, cwd: string, explicitPath = false): ExistingTargetInspection {
|
|
953
|
+
const resolution = resolveRalphTargetResolution(input, cwd);
|
|
954
|
+
const absoluteTarget = resolution.absoluteTarget;
|
|
955
|
+
const markdownPath = resolution.markdownPath;
|
|
956
|
+
|
|
957
|
+
if (existsSync(absoluteTarget)) {
|
|
958
|
+
const stats = statSync(absoluteTarget);
|
|
959
|
+
if (stats.isDirectory()) {
|
|
960
|
+
return existsSync(markdownPath)
|
|
961
|
+
? { kind: "run", ralphPath: markdownPath }
|
|
962
|
+
: { kind: "dir-without-ralph", dirPath: absoluteTarget, ralphPath: markdownPath };
|
|
963
|
+
}
|
|
964
|
+
if (isRalphMarkdownPath(absoluteTarget)) {
|
|
965
|
+
return { kind: "run", ralphPath: absoluteTarget };
|
|
966
|
+
}
|
|
967
|
+
if (absoluteTarget.endsWith(".md")) {
|
|
968
|
+
return { kind: "invalid-markdown", path: absoluteTarget };
|
|
969
|
+
}
|
|
970
|
+
return { kind: "invalid-target", path: absoluteTarget };
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (!explicitPath && !looksLikePath(input)) {
|
|
974
|
+
return { kind: "not-path" };
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (absoluteTarget.endsWith(".md")) {
|
|
978
|
+
return { kind: "missing-path", ...normalizeMissingMarkdownTarget(absoluteTarget) };
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
return { kind: "missing-path", dirPath: absoluteTarget, ralphPath: markdownPath };
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
export function slugifyTask(task: string): string {
|
|
985
|
+
const slug = task
|
|
986
|
+
.toLowerCase()
|
|
987
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
988
|
+
.replace(/^-+|-+$/g, "")
|
|
989
|
+
.slice(0, 80)
|
|
990
|
+
.replace(/^-+|-+$/g, "");
|
|
991
|
+
return slug || "ralph-task";
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
export function nextSiblingSlug(baseSlug: string, hasRalphAtSlug: (slug: string) => boolean): string {
|
|
995
|
+
let suffix = 2;
|
|
996
|
+
let next = `${baseSlug}-${suffix}`;
|
|
997
|
+
while (hasRalphAtSlug(next)) {
|
|
998
|
+
suffix += 1;
|
|
999
|
+
next = `${baseSlug}-${suffix}`;
|
|
1000
|
+
}
|
|
1001
|
+
return next;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export function classifyTaskMode(task: string): DraftMode {
|
|
1005
|
+
const normalized = task.toLowerCase();
|
|
1006
|
+
if (/(reverse engineer|analy[sz]e|understand|investigate|map|audit|explore)/.test(normalized)) return "analysis";
|
|
1007
|
+
if (/(fix|debug|repair|failing test|flaky|failure|broken)/.test(normalized)) return "fix";
|
|
1008
|
+
if (/(migrate|upgrade|convert|port|modernize)/.test(normalized)) return "migration";
|
|
1009
|
+
return "general";
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
export function planTaskDraftTarget(cwd: string, task: string): PlannedTaskTarget {
|
|
1013
|
+
const slug = slugifyTask(task);
|
|
1014
|
+
const target: DraftTarget = {
|
|
1015
|
+
slug,
|
|
1016
|
+
dirPath: join(cwd, slug),
|
|
1017
|
+
ralphPath: join(cwd, slug, "RALPH.md"),
|
|
1018
|
+
};
|
|
1019
|
+
return existsSync(target.dirPath) ? { kind: "conflict", target } : { kind: "draft", target };
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
export function createSiblingTarget(cwd: string, baseSlug: string): DraftTarget {
|
|
1023
|
+
const siblingSlug = nextSiblingSlug(baseSlug, (candidate) => existsSync(join(cwd, candidate)));
|
|
1024
|
+
return {
|
|
1025
|
+
slug: siblingSlug,
|
|
1026
|
+
dirPath: join(cwd, siblingSlug),
|
|
1027
|
+
ralphPath: join(cwd, siblingSlug, "RALPH.md"),
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function inspectRepo(cwd: string): RepoSignals {
|
|
1032
|
+
const packageManager = detectPackageManager(cwd);
|
|
1033
|
+
const packageScripts = detectPackageScripts(cwd, packageManager);
|
|
1034
|
+
let topLevelDirs: string[] = [];
|
|
1035
|
+
let topLevelFiles: string[] = [];
|
|
1036
|
+
|
|
1037
|
+
try {
|
|
1038
|
+
const entries = readdirSync(cwd, { withFileTypes: true }).slice(0, 50);
|
|
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);
|
|
1042
|
+
} catch {
|
|
1043
|
+
// ignore bounded inspection failures
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
packageManager,
|
|
1048
|
+
testCommand: packageScripts.testCommand,
|
|
1049
|
+
lintCommand: packageScripts.lintCommand,
|
|
1050
|
+
hasGit: existsSync(join(cwd, ".git")),
|
|
1051
|
+
topLevelDirs,
|
|
1052
|
+
topLevelFiles,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
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[] {
|
|
1102
|
+
if (mode === "analysis") {
|
|
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" });
|
|
1105
|
+
return commands;
|
|
1106
|
+
}
|
|
1107
|
+
|
|
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" });
|
|
1113
|
+
return commands;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
export function suggestedCommandsForMode(mode: DraftMode, signals: RepoSignals): CommandDef[] {
|
|
1117
|
+
return buildCommandIntent(mode, signals).map(({ source: _source, ...command }) => command);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function formatCommandLabel(command: CommandDef): string {
|
|
1121
|
+
return `${command.name}: ${command.run}`;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function extractVisibleTask(body: string): string | undefined {
|
|
1125
|
+
const match = body.match(/^Task:\s*(.+)$/m);
|
|
1126
|
+
return match?.[1]?.trim() || undefined;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function buildDraftFrontmatter(mode: DraftMode, commands: CommandDef[]): Frontmatter {
|
|
1130
|
+
const guardrails = {
|
|
1131
|
+
blockCommands: ["git\\s+push"],
|
|
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,
|
|
1141
|
+
};
|
|
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 ?? [];
|
|
1175
|
+
const frontmatterLines = [
|
|
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)}`] : []),
|
|
1184
|
+
"guardrails:",
|
|
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: []"]),
|
|
1191
|
+
];
|
|
1192
|
+
|
|
1193
|
+
return {
|
|
1194
|
+
task,
|
|
1195
|
+
mode,
|
|
1196
|
+
target,
|
|
1197
|
+
source,
|
|
1198
|
+
content: `${metadataComment(metadata)}\n${yamlBlock(frontmatterLines)}\n\n${body}`,
|
|
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,
|
|
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");
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
export function extractDraftMetadata(raw: string): DraftMetadata | undefined {
|
|
1263
|
+
const match = raw.match(/^<!-- pi-ralph-loop: (.+?) -->/);
|
|
1264
|
+
if (!match) return undefined;
|
|
1265
|
+
|
|
1266
|
+
try {
|
|
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;
|
|
1280
|
+
} catch {
|
|
1281
|
+
return undefined;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
export function shouldValidateExistingDraft(raw: string): boolean {
|
|
1286
|
+
return extractDraftMetadata(raw) !== undefined;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
export type DraftContentInspection = {
|
|
1290
|
+
metadata?: DraftMetadata;
|
|
1291
|
+
parsed?: ParsedRalph;
|
|
1292
|
+
error?: string;
|
|
1293
|
+
};
|
|
1294
|
+
|
|
1295
|
+
export function inspectDraftContent(raw: string): DraftContentInspection {
|
|
1296
|
+
const metadata = extractDraftMetadata(raw);
|
|
1297
|
+
const parsed = parseStrictRalphMarkdown(raw);
|
|
1298
|
+
|
|
1299
|
+
if ("error" in parsed) {
|
|
1300
|
+
return { metadata, error: parsed.error };
|
|
1301
|
+
}
|
|
1302
|
+
|
|
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" };
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
const error = validateFrontmatter(parsed.parsed.frontmatter);
|
|
1309
|
+
return error ? { metadata, parsed: parsed.parsed, error } : { metadata, parsed: parsed.parsed };
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
export function validateDraftContent(raw: string): string | null {
|
|
1313
|
+
return inspectDraftContent(raw).error ?? null;
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
export function buildMissionBrief(plan: DraftPlan): string {
|
|
1317
|
+
const inspection = inspectDraftContent(plan.content);
|
|
1318
|
+
const task = extractVisibleTask(inspection.parsed?.body ?? "") ?? inspection.metadata?.task ?? "Task metadata missing from current draft";
|
|
1319
|
+
|
|
1320
|
+
if (inspection.error) {
|
|
1321
|
+
return [
|
|
1322
|
+
"Mission Brief",
|
|
1323
|
+
"Review what Ralph will do before it starts.",
|
|
1324
|
+
"",
|
|
1325
|
+
"Task",
|
|
1326
|
+
task,
|
|
1327
|
+
"",
|
|
1328
|
+
"File",
|
|
1329
|
+
plan.target.ralphPath,
|
|
1330
|
+
"",
|
|
1331
|
+
"Draft status",
|
|
1332
|
+
`- Invalid RALPH.md: ${inspection.error}`,
|
|
1333
|
+
"- Reopen RALPH.md to fix it or cancel",
|
|
1334
|
+
].join("\n");
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
const parsed = inspection.parsed!;
|
|
1338
|
+
const commandLabels = parsed.frontmatter.commands.map(formatCommandLabel);
|
|
1339
|
+
const finishBehavior = summarizeFinishBehavior(parsed.frontmatter);
|
|
1340
|
+
const safetyLabel = summarizeSafetyLabel(parsed.frontmatter.guardrails);
|
|
1341
|
+
|
|
1342
|
+
return [
|
|
1343
|
+
"Mission Brief",
|
|
1344
|
+
"Review what Ralph will do before it starts.",
|
|
1345
|
+
"",
|
|
1346
|
+
"Task",
|
|
1347
|
+
task,
|
|
1348
|
+
"",
|
|
1349
|
+
"File",
|
|
1350
|
+
plan.target.ralphPath,
|
|
1351
|
+
"",
|
|
1352
|
+
"Suggested checks",
|
|
1353
|
+
...commandLabels.map((label) => `- ${label}`),
|
|
1354
|
+
"",
|
|
1355
|
+
"Finish behavior",
|
|
1356
|
+
...finishBehavior,
|
|
1357
|
+
"",
|
|
1358
|
+
"Safety",
|
|
1359
|
+
`- ${safetyLabel}`,
|
|
1360
|
+
].join("\n");
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
export function extractCompletionPromise(text: string): string | undefined {
|
|
1364
|
+
const match = text.match(/<promise>([^<]+)<\/promise>/);
|
|
1365
|
+
return match?.[1]?.trim() || undefined;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
export function shouldStopForCompletionPromise(text: string, expected: string): boolean {
|
|
1369
|
+
return extractCompletionPromise(text) === expected.trim();
|
|
1370
|
+
}
|
|
1371
|
+
|
|
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 {
|
|
1392
|
+
const map = new Map(outputs.map((o) => [o.name, o.output]));
|
|
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);
|
|
1405
|
+
}
|
|
1406
|
+
|
|
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, "");
|
|
1414
|
+
}
|
|
1415
|
+
|
|
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")}`;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
export function shouldWarnForBashFailure(output: string): boolean {
|
|
1446
|
+
return /FAIL|ERROR|error:|failed/i.test(output);
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
export function classifyIdleState(timedOut: boolean, idleError?: Error): "ok" | "timeout" | "error" {
|
|
1450
|
+
if (timedOut) return "timeout";
|
|
1451
|
+
if (idleError) return "error";
|
|
1452
|
+
return "ok";
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
export function shouldResetFailCount(previousSessionFile?: string, nextSessionFile?: string): boolean {
|
|
1456
|
+
return Boolean(previousSessionFile && nextSessionFile && previousSessionFile !== nextSessionFile);
|
|
1457
|
+
}
|