ralph-cli-sandboxed 0.2.2 → 0.2.4
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/README.md +70 -24
- package/dist/commands/docker.d.ts +5 -0
- package/dist/commands/docker.js +44 -0
- package/dist/commands/fix-prd.d.ts +1 -0
- package/dist/commands/fix-prd.js +201 -0
- package/dist/commands/help.js +8 -0
- package/dist/commands/init.js +8 -5
- package/dist/commands/once.js +24 -4
- package/dist/commands/run.js +151 -22
- package/dist/config/cli-providers.json +36 -8
- package/dist/index.js +2 -0
- package/dist/templates/prompts.d.ts +5 -0
- package/dist/templates/prompts.js +3 -3
- package/dist/utils/config.d.ts +1 -0
- package/dist/utils/config.js +17 -15
- package/dist/utils/prd-validator.d.ts +80 -0
- package/dist/utils/prd-validator.js +417 -0
- package/package.json +1 -1
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync } from "fs";
|
|
2
|
+
import { join, dirname } from "path";
|
|
3
|
+
const VALID_CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
|
|
4
|
+
/**
|
|
5
|
+
* Validates that a PRD structure is correct.
|
|
6
|
+
* Returns validation result with parsed data if valid.
|
|
7
|
+
*/
|
|
8
|
+
export function validatePrd(content) {
|
|
9
|
+
const errors = [];
|
|
10
|
+
// Must be an array
|
|
11
|
+
if (!Array.isArray(content)) {
|
|
12
|
+
errors.push("PRD must be a JSON array");
|
|
13
|
+
return { valid: false, errors };
|
|
14
|
+
}
|
|
15
|
+
const data = [];
|
|
16
|
+
for (let i = 0; i < content.length; i++) {
|
|
17
|
+
const item = content[i];
|
|
18
|
+
const prefix = `Item ${i + 1}:`;
|
|
19
|
+
if (typeof item !== "object" || item === null) {
|
|
20
|
+
errors.push(`${prefix} must be an object`);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const entry = item;
|
|
24
|
+
// Check required fields
|
|
25
|
+
if (typeof entry.category !== "string") {
|
|
26
|
+
errors.push(`${prefix} missing or invalid 'category' field`);
|
|
27
|
+
}
|
|
28
|
+
else if (!VALID_CATEGORIES.includes(entry.category)) {
|
|
29
|
+
errors.push(`${prefix} invalid category '${entry.category}'`);
|
|
30
|
+
}
|
|
31
|
+
if (typeof entry.description !== "string" || entry.description.length === 0) {
|
|
32
|
+
errors.push(`${prefix} missing or invalid 'description' field`);
|
|
33
|
+
}
|
|
34
|
+
if (!Array.isArray(entry.steps)) {
|
|
35
|
+
errors.push(`${prefix} missing or invalid 'steps' field (must be array)`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
for (let j = 0; j < entry.steps.length; j++) {
|
|
39
|
+
if (typeof entry.steps[j] !== "string") {
|
|
40
|
+
errors.push(`${prefix} step ${j + 1} must be a string`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (typeof entry.passes !== "boolean") {
|
|
45
|
+
errors.push(`${prefix} missing or invalid 'passes' field (must be boolean)`);
|
|
46
|
+
}
|
|
47
|
+
// If no errors for this item, add to valid data
|
|
48
|
+
if (errors.filter(e => e.startsWith(prefix)).length === 0) {
|
|
49
|
+
data.push({
|
|
50
|
+
category: entry.category,
|
|
51
|
+
description: entry.description,
|
|
52
|
+
steps: entry.steps,
|
|
53
|
+
passes: entry.passes,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (errors.length > 0) {
|
|
58
|
+
return { valid: false, errors };
|
|
59
|
+
}
|
|
60
|
+
return { valid: true, errors: [], data };
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Extracts items marked as passing from a corrupted PRD structure.
|
|
64
|
+
* Handles various malformed structures LLMs might create.
|
|
65
|
+
*/
|
|
66
|
+
export function extractPassingItems(corrupted) {
|
|
67
|
+
const items = [];
|
|
68
|
+
// Handle null/undefined
|
|
69
|
+
if (corrupted === null || corrupted === undefined) {
|
|
70
|
+
return items;
|
|
71
|
+
}
|
|
72
|
+
// Handle direct array
|
|
73
|
+
if (Array.isArray(corrupted)) {
|
|
74
|
+
for (const item of corrupted) {
|
|
75
|
+
const extracted = extractFromItem(item);
|
|
76
|
+
if (extracted) {
|
|
77
|
+
items.push(extracted);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return items;
|
|
81
|
+
}
|
|
82
|
+
// Handle object with wrapped arrays
|
|
83
|
+
if (typeof corrupted === "object") {
|
|
84
|
+
const obj = corrupted;
|
|
85
|
+
// Common wrapper keys LLMs might use
|
|
86
|
+
const wrapperKeys = ["features", "items", "entries", "prd", "tasks", "requirements"];
|
|
87
|
+
for (const key of wrapperKeys) {
|
|
88
|
+
if (Array.isArray(obj[key])) {
|
|
89
|
+
for (const item of obj[key]) {
|
|
90
|
+
const extracted = extractFromItem(item);
|
|
91
|
+
if (extracted) {
|
|
92
|
+
items.push(extracted);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return items;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Try to extract from object directly (in case it's a single item)
|
|
99
|
+
const extracted = extractFromItem(obj);
|
|
100
|
+
if (extracted) {
|
|
101
|
+
items.push(extracted);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return items;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Extracts description and passes status from an item,
|
|
108
|
+
* handling various field names LLMs might use.
|
|
109
|
+
*/
|
|
110
|
+
function extractFromItem(item) {
|
|
111
|
+
if (typeof item !== "object" || item === null) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
const obj = item;
|
|
115
|
+
// Find description - check various field names
|
|
116
|
+
const descriptionFields = ["description", "desc", "name", "title", "task", "feature"];
|
|
117
|
+
let description = "";
|
|
118
|
+
for (const field of descriptionFields) {
|
|
119
|
+
if (typeof obj[field] === "string" && obj[field]) {
|
|
120
|
+
description = obj[field];
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (!description) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
// Find passes status - check various field names and values
|
|
128
|
+
const passesFields = ["passes", "pass", "passed", "done", "complete", "completed", "status", "finished"];
|
|
129
|
+
let passes = false;
|
|
130
|
+
for (const field of passesFields) {
|
|
131
|
+
const value = obj[field];
|
|
132
|
+
if (typeof value === "boolean") {
|
|
133
|
+
passes = value;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
if (typeof value === "string") {
|
|
137
|
+
const lower = value.toLowerCase();
|
|
138
|
+
if (lower === "true" || lower === "pass" || lower === "passed" || lower === "done" || lower === "complete" || lower === "completed" || lower === "finished") {
|
|
139
|
+
passes = true;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { description, passes };
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Calculates similarity between two strings using Jaccard index on words.
|
|
148
|
+
*/
|
|
149
|
+
function similarity(a, b) {
|
|
150
|
+
const wordsA = new Set(a.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
|
151
|
+
const wordsB = new Set(b.toLowerCase().split(/\s+/).filter(w => w.length > 2));
|
|
152
|
+
if (wordsA.size === 0 || wordsB.size === 0) {
|
|
153
|
+
return 0;
|
|
154
|
+
}
|
|
155
|
+
const intersection = new Set([...wordsA].filter(x => wordsB.has(x)));
|
|
156
|
+
const union = new Set([...wordsA, ...wordsB]);
|
|
157
|
+
return intersection.size / union.size;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Smart merge: applies passes flags from corrupted PRD to valid original.
|
|
161
|
+
* Only updates items that were marked as passing in the corrupted version.
|
|
162
|
+
*/
|
|
163
|
+
export function smartMerge(original, corrupted) {
|
|
164
|
+
const passingItems = extractPassingItems(corrupted);
|
|
165
|
+
const merged = original.map(entry => ({ ...entry })); // Deep copy
|
|
166
|
+
let updated = 0;
|
|
167
|
+
const warnings = [];
|
|
168
|
+
for (const item of passingItems) {
|
|
169
|
+
if (!item.passes)
|
|
170
|
+
continue;
|
|
171
|
+
// Find matching original item by description similarity
|
|
172
|
+
let bestMatch = null;
|
|
173
|
+
let bestScore = 0;
|
|
174
|
+
for (const entry of merged) {
|
|
175
|
+
// Exact substring match
|
|
176
|
+
if (entry.description.includes(item.description) || item.description.includes(entry.description)) {
|
|
177
|
+
bestMatch = entry;
|
|
178
|
+
bestScore = 1;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
// Similarity match
|
|
182
|
+
const score = similarity(entry.description, item.description);
|
|
183
|
+
if (score > bestScore && score > 0.5) {
|
|
184
|
+
bestMatch = entry;
|
|
185
|
+
bestScore = score;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
if (bestMatch && !bestMatch.passes) {
|
|
189
|
+
bestMatch.passes = true;
|
|
190
|
+
updated++;
|
|
191
|
+
}
|
|
192
|
+
else if (!bestMatch) {
|
|
193
|
+
warnings.push(`Could not match item: "${item.description.substring(0, 50)}..."`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return { merged, itemsUpdated: updated, warnings };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Attempts to recover a valid PRD from corrupted content.
|
|
200
|
+
* Returns the recovered PRD entries or null if recovery failed.
|
|
201
|
+
*/
|
|
202
|
+
export function attemptRecovery(corrupted) {
|
|
203
|
+
// Strategy 1: Unwrap from common wrapper objects
|
|
204
|
+
if (typeof corrupted === "object" && corrupted !== null && !Array.isArray(corrupted)) {
|
|
205
|
+
const obj = corrupted;
|
|
206
|
+
const wrapperKeys = ["features", "items", "entries", "prd", "tasks", "requirements"];
|
|
207
|
+
for (const key of wrapperKeys) {
|
|
208
|
+
if (Array.isArray(obj[key])) {
|
|
209
|
+
const result = attemptArrayRecovery(obj[key]);
|
|
210
|
+
if (result)
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Strategy 2: Direct array recovery with field mapping
|
|
216
|
+
if (Array.isArray(corrupted)) {
|
|
217
|
+
const result = attemptArrayRecovery(corrupted);
|
|
218
|
+
if (result)
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Attempts to recover PRD entries from an array with possibly renamed fields.
|
|
225
|
+
*/
|
|
226
|
+
function attemptArrayRecovery(items) {
|
|
227
|
+
const recovered = [];
|
|
228
|
+
for (const item of items) {
|
|
229
|
+
if (typeof item !== "object" || item === null) {
|
|
230
|
+
return null; // Can't recover if items aren't objects
|
|
231
|
+
}
|
|
232
|
+
const obj = item;
|
|
233
|
+
// Map fields to standard names
|
|
234
|
+
const entry = {};
|
|
235
|
+
// Category mapping
|
|
236
|
+
const categoryFields = ["category", "cat", "type", "id"];
|
|
237
|
+
for (const field of categoryFields) {
|
|
238
|
+
if (typeof obj[field] === "string") {
|
|
239
|
+
const value = obj[field];
|
|
240
|
+
if (VALID_CATEGORIES.includes(value)) {
|
|
241
|
+
entry.category = value;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
// Description mapping
|
|
247
|
+
const descFields = ["description", "desc", "name", "title", "task", "feature"];
|
|
248
|
+
for (const field of descFields) {
|
|
249
|
+
if (typeof obj[field] === "string" && obj[field]) {
|
|
250
|
+
entry.description = obj[field];
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
// Steps mapping
|
|
255
|
+
const stepsFields = ["steps", "verification", "checks", "tasks"];
|
|
256
|
+
for (const field of stepsFields) {
|
|
257
|
+
if (Array.isArray(obj[field])) {
|
|
258
|
+
const steps = obj[field].filter(s => typeof s === "string");
|
|
259
|
+
if (steps.length > 0) {
|
|
260
|
+
entry.steps = steps;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Passes mapping
|
|
266
|
+
const passesFields = ["passes", "pass", "passed", "done", "complete", "completed", "status", "finished"];
|
|
267
|
+
for (const field of passesFields) {
|
|
268
|
+
const value = obj[field];
|
|
269
|
+
if (typeof value === "boolean") {
|
|
270
|
+
entry.passes = value;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
if (typeof value === "string") {
|
|
274
|
+
const lower = value.toLowerCase();
|
|
275
|
+
if (lower === "true" || lower === "pass" || lower === "passed" || lower === "done" || lower === "complete" || lower === "completed" || lower === "finished") {
|
|
276
|
+
entry.passes = true;
|
|
277
|
+
break;
|
|
278
|
+
}
|
|
279
|
+
if (lower === "false" || lower === "fail" || lower === "failed" || lower === "pending" || lower === "incomplete") {
|
|
280
|
+
entry.passes = false;
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Check if we recovered all required fields
|
|
286
|
+
if (!entry.category || !entry.description) {
|
|
287
|
+
return null; // Missing critical fields
|
|
288
|
+
}
|
|
289
|
+
// Default missing optional fields
|
|
290
|
+
if (!entry.steps) {
|
|
291
|
+
entry.steps = ["Verify the feature works as expected"];
|
|
292
|
+
}
|
|
293
|
+
if (entry.passes === undefined) {
|
|
294
|
+
entry.passes = false;
|
|
295
|
+
}
|
|
296
|
+
recovered.push(entry);
|
|
297
|
+
}
|
|
298
|
+
return recovered.length > 0 ? recovered : null;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Creates a timestamped backup of the PRD file.
|
|
302
|
+
* Returns the backup path.
|
|
303
|
+
*/
|
|
304
|
+
export function createBackup(prdPath) {
|
|
305
|
+
const content = readFileSync(prdPath, "utf-8");
|
|
306
|
+
const dir = dirname(prdPath);
|
|
307
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
308
|
+
const backupPath = join(dir, `backup.prd.${timestamp}.json`);
|
|
309
|
+
writeFileSync(backupPath, content);
|
|
310
|
+
return backupPath;
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Finds the most recent backup file.
|
|
314
|
+
* Returns the path or null if no backups exist.
|
|
315
|
+
*/
|
|
316
|
+
export function findLatestBackup(prdPath) {
|
|
317
|
+
const dir = dirname(prdPath);
|
|
318
|
+
if (!existsSync(dir)) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
const files = readdirSync(dir);
|
|
322
|
+
const backups = files
|
|
323
|
+
.filter(f => f.startsWith("backup.prd.") && f.endsWith(".json"))
|
|
324
|
+
.sort()
|
|
325
|
+
.reverse();
|
|
326
|
+
if (backups.length === 0) {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
return join(dir, backups[0]);
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Creates a PRD template with a recovery entry that instructs the LLM to fix the PRD.
|
|
333
|
+
* Uses @{filepath} syntax to include backup content when expanded.
|
|
334
|
+
* @param backupPath - Absolute path to the backup file containing the corrupted PRD
|
|
335
|
+
*/
|
|
336
|
+
export function createTemplatePrd(backupPath) {
|
|
337
|
+
if (backupPath) {
|
|
338
|
+
// Use absolute path in @{} reference to avoid path resolution issues
|
|
339
|
+
const absolutePath = backupPath.startsWith("/") ? backupPath : join(process.cwd(), backupPath);
|
|
340
|
+
return [
|
|
341
|
+
{
|
|
342
|
+
category: "setup",
|
|
343
|
+
description: "Fix the PRD entries",
|
|
344
|
+
steps: [
|
|
345
|
+
`Recreate PRD entries based on this corrupted backup content:\n\n@{${absolutePath}}`,
|
|
346
|
+
"Write valid entries to .ralph/prd.json with format: category (string), description (string), steps (array of strings), passes (boolean)"
|
|
347
|
+
],
|
|
348
|
+
passes: false,
|
|
349
|
+
}
|
|
350
|
+
];
|
|
351
|
+
}
|
|
352
|
+
return [
|
|
353
|
+
{
|
|
354
|
+
category: "setup",
|
|
355
|
+
description: "Add PRD entries",
|
|
356
|
+
steps: [
|
|
357
|
+
"Add requirements using 'ralph add' or edit .ralph/prd.json directly",
|
|
358
|
+
"Verify format: category (string), description (string), steps (array of strings), passes (boolean)"
|
|
359
|
+
],
|
|
360
|
+
passes: false,
|
|
361
|
+
}
|
|
362
|
+
];
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Reads and parses a PRD file, handling potential JSON errors.
|
|
366
|
+
* Returns the parsed content or null if it couldn't be parsed.
|
|
367
|
+
*/
|
|
368
|
+
export function readPrdFile(prdPath) {
|
|
369
|
+
try {
|
|
370
|
+
const raw = readFileSync(prdPath, "utf-8");
|
|
371
|
+
const content = JSON.parse(raw);
|
|
372
|
+
return { content, raw };
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Writes a PRD to file.
|
|
380
|
+
*/
|
|
381
|
+
export function writePrd(prdPath, entries) {
|
|
382
|
+
writeFileSync(prdPath, JSON.stringify(entries, null, 2) + "\n");
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Expands @{filepath} patterns in a string with actual file contents.
|
|
386
|
+
* Similar to curl's @ syntax for including file contents.
|
|
387
|
+
* Paths are resolved relative to the .ralph directory.
|
|
388
|
+
*/
|
|
389
|
+
export function expandFileReferences(text, baseDir) {
|
|
390
|
+
// Match @{filepath} patterns
|
|
391
|
+
const pattern = /@\{([^}]+)\}/g;
|
|
392
|
+
return text.replace(pattern, (match, filepath) => {
|
|
393
|
+
// Resolve path relative to baseDir (typically .ralph/)
|
|
394
|
+
const fullPath = filepath.startsWith("/") ? filepath : join(baseDir, filepath);
|
|
395
|
+
if (!existsSync(fullPath)) {
|
|
396
|
+
return `[File not found: ${fullPath}]`;
|
|
397
|
+
}
|
|
398
|
+
try {
|
|
399
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
400
|
+
return content;
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
return `[Error reading: ${fullPath}]`;
|
|
404
|
+
}
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Expands file references in all string fields of PRD entries.
|
|
409
|
+
* Returns a new array with expanded content.
|
|
410
|
+
*/
|
|
411
|
+
export function expandPrdFileReferences(entries, baseDir) {
|
|
412
|
+
return entries.map(entry => ({
|
|
413
|
+
...entry,
|
|
414
|
+
description: expandFileReferences(entry.description, baseDir),
|
|
415
|
+
steps: entry.steps.map(step => expandFileReferences(step, baseDir)),
|
|
416
|
+
}));
|
|
417
|
+
}
|