ralph-cli-sandboxed 0.6.2 → 0.6.3

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.
@@ -92,7 +92,7 @@ BRANCH SUBCOMMANDS:
92
92
  branch list List all branches and their status
93
93
  branch merge <name> Merge a branch worktree into the base branch
94
94
  branch delete <name> Delete a branch and remove its worktree
95
- branch pr <name> Create a PRD item to open a PR for a branch
95
+ branch pr <name> Create a pull request for a branch using gh CLI
96
96
 
97
97
  PROGRESS SUBCOMMANDS:
98
98
  progress summarize Add a PRD entry to summarize and compact progress.txt
@@ -149,7 +149,7 @@ EXAMPLES:
149
149
  ralph branch list # List all branches and their PRD status
150
150
  ralph branch merge feat/login # Merge feat/login branch into base branch
151
151
  ralph branch delete feat/old # Delete branch and its worktree
152
- ralph branch pr feat/login # Add PRD item to create PR for feat/login
152
+ ralph branch pr feat/login # Create a GitHub PR for feat/login
153
153
  ralph progress summarize # Add PRD entry to summarize progress.txt
154
154
 
155
155
  CONFIGURATION:
@@ -5,6 +5,7 @@ import { getRalphDir, getPrdFiles } from "../utils/config.js";
5
5
  import { convert as prdConvert } from "./prd-convert.js";
6
6
  import { DEFAULT_PRD_YAML } from "../templates/prompts.js";
7
7
  import YAML from "yaml";
8
+ import { robustYamlParse } from "../utils/prd-validator.js";
8
9
  const PRD_FILE_JSON = "prd.json";
9
10
  const PRD_FILE_YAML = "prd.yaml";
10
11
  const CATEGORIES = ["ui", "feature", "bugfix", "setup", "development", "testing", "docs"];
@@ -29,7 +30,7 @@ function parsePrdFile(path) {
29
30
  try {
30
31
  let result;
31
32
  if (ext === ".yaml" || ext === ".yml") {
32
- result = YAML.parse(content);
33
+ result = robustYamlParse(content);
33
34
  }
34
35
  else {
35
36
  result = JSON.parse(content);
@@ -227,6 +228,24 @@ export function prdStatus(headOnly = false) {
227
228
  Object.entries(byCategory).forEach(([cat, stats]) => {
228
229
  console.log(` ${cat}: ${stats.pass}/${stats.total}`);
229
230
  });
231
+ // By branch (only if any entries have a branch)
232
+ const hasBranches = prd.some((e) => e.branch);
233
+ if (hasBranches) {
234
+ const byBranch = {};
235
+ prd.forEach((entry) => {
236
+ const key = entry.branch || "(no branch)";
237
+ if (!byBranch[key]) {
238
+ byBranch[key] = { pass: 0, total: 0 };
239
+ }
240
+ byBranch[key].total++;
241
+ if (entry.passes)
242
+ byBranch[key].pass++;
243
+ });
244
+ console.log("\n By branch:");
245
+ Object.entries(byBranch).forEach(([br, stats]) => {
246
+ console.log(` ${br}: ${stats.pass}/${stats.total}`);
247
+ });
248
+ }
230
249
  if (passing === total) {
231
250
  console.log("\n \x1b[32m\u2713 All requirements complete!\x1b[0m");
232
251
  }
@@ -234,7 +253,8 @@ export function prdStatus(headOnly = false) {
234
253
  const remaining = prd.filter((e) => !e.passes);
235
254
  console.log(`\n Remaining (${remaining.length}):`);
236
255
  remaining.forEach((entry) => {
237
- console.log(` - [${entry.category}] ${entry.description}`);
256
+ const branchTag = entry.branch ? ` \x1b[36m(${entry.branch})\x1b[0m` : "";
257
+ console.log(` - [${entry.category}] ${entry.description}${branchTag}`);
238
258
  });
239
259
  }
240
260
  }
@@ -3,6 +3,7 @@ import { extname, join } from "path";
3
3
  import { getRalphDir, getPrdFiles } from "../utils/config.js";
4
4
  import { DEFAULT_PRD_YAML } from "../templates/prompts.js";
5
5
  import YAML from "yaml";
6
+ import { robustYamlParse } from "../utils/prd-validator.js";
6
7
  const PRD_FILE_YAML = "prd.yaml";
7
8
  const PRD_FILE_JSON = "prd.json";
8
9
  function getPrdPath() {
@@ -18,7 +19,7 @@ function parsePrdFile(path) {
18
19
  try {
19
20
  let result;
20
21
  if (ext === ".yaml" || ext === ".yml") {
21
- result = YAML.parse(content);
22
+ result = robustYamlParse(content);
22
23
  }
23
24
  else {
24
25
  result = JSON.parse(content);
@@ -888,15 +888,32 @@ export async function run(args) {
888
888
  // Default to "main"
889
889
  }
890
890
  }
891
+ // Merge items tagged with the base branch into the no-branch group,
892
+ // so they run in /workspace instead of creating a worktree.
893
+ const baseBranchItems = branchGroups.get(baseBranch);
894
+ if (baseBranchItems && baseBranchItems.length > 0) {
895
+ const noBranch = branchGroups.get("") || [];
896
+ branchGroups.set("", [...noBranch, ...baseBranchItems]);
897
+ branchGroups.delete(baseBranch);
898
+ }
891
899
  // Find the first incomplete item to determine which group to process.
892
900
  // If resuming from a previous interruption, prioritize the resumed branch.
893
901
  let targetBranch;
894
902
  if (resumedBranchState) {
895
903
  targetBranch = resumedBranchState.currentBranch;
904
+ // If resumed branch is the base branch, treat as no-branch
905
+ if (targetBranch === baseBranch) {
906
+ targetBranch = "";
907
+ clearBranchState();
908
+ }
896
909
  }
897
910
  else {
898
911
  const firstIncomplete = itemsForIteration.find((item) => !item.passes);
899
912
  targetBranch = firstIncomplete?.branch || "";
913
+ // If the target matches the base branch, treat as no-branch
914
+ if (targetBranch === baseBranch) {
915
+ targetBranch = "";
916
+ }
900
917
  }
901
918
  if (targetBranch !== "" && worktreesAvailable && hasCommits) {
902
919
  // Process this one branch group in its worktree
@@ -57,6 +57,11 @@ export declare function findLatestBackup(prdPath: string): string | null;
57
57
  * @param backupPath - Absolute path to the backup file containing the corrupted PRD
58
58
  */
59
59
  export declare function createTemplatePrd(backupPath?: string): PrdEntry[];
60
+ /**
61
+ * Robustly parses YAML content, applying automatic fixes for common
62
+ * LLM-generated YAML issues (multiline strings, embedded quotes).
63
+ */
64
+ export declare function robustYamlParse(content: string): unknown;
60
65
  /**
61
66
  * Reads and parses a YAML PRD file.
62
67
  * Attempts to fix common LLM-caused YAML issues before parsing.
@@ -425,6 +425,64 @@ export function createTemplatePrd(backupPath) {
425
425
  },
426
426
  ];
427
427
  }
428
+ /**
429
+ * Checks if a YAML plain scalar value contains embedded double-quoted segments
430
+ * with YAML special characters that would cause parsing issues.
431
+ *
432
+ * Example: Add "server: { port: 9999 }" to vite.config.ts
433
+ * The parser interprets "server: { port: 9999 }" as a double-quoted scalar,
434
+ * then finds unexpected text after the closing quote.
435
+ */
436
+ function hasProblematicEmbeddedQuotes(value) {
437
+ // Look for "...special chars..." followed by more text
438
+ const regex = /"[^"]*[:{}\[\]][^"]*"/g;
439
+ let match;
440
+ while ((match = regex.exec(value)) !== null) {
441
+ const afterQuote = value.substring(match.index + match[0].length);
442
+ if (afterQuote.trim().length > 0) {
443
+ return true;
444
+ }
445
+ }
446
+ return false;
447
+ }
448
+ /**
449
+ * Fixes YAML values that contain embedded double-quoted strings with special characters.
450
+ *
451
+ * Example problematic line:
452
+ * - Add "server: { port: 9999 }" to vite.config.ts
453
+ *
454
+ * The YAML parser interprets "server: { port: 9999 }" as a double-quoted scalar,
455
+ * then chokes on the trailing text. Fix: wrap the entire value in single quotes.
456
+ */
457
+ function fixYamlEmbeddedQuotes(yaml) {
458
+ const lines = yaml.split("\n");
459
+ const result = [];
460
+ for (const line of lines) {
461
+ // Try list item: ` - value`
462
+ let match = line.match(/^(\s*-\s+)(.+)$/);
463
+ if (!match) {
464
+ // Try key-value: ` key: value`
465
+ match = line.match(/^(\s*[a-zA-Z_][a-zA-Z0-9_]*:\s+)(.+)$/);
466
+ }
467
+ if (match) {
468
+ const prefix = match[1];
469
+ const value = match[2];
470
+ // Skip if already quoted
471
+ if (value.startsWith('"') || value.startsWith("'") || value.startsWith("|") || value.startsWith(">")) {
472
+ result.push(line);
473
+ continue;
474
+ }
475
+ if (hasProblematicEmbeddedQuotes(value)) {
476
+ // Wrap in single quotes, escaping existing single quotes by doubling
477
+ const escaped = value.replace(/'/g, "''");
478
+ result.push(`${prefix}'${escaped}'`);
479
+ continue;
480
+ }
481
+ }
482
+ result.push(line);
483
+ }
484
+ return result.join("\n");
485
+ }
428
486
  /**
429
487
  * Fixes common YAML issues caused by LLMs writing multi-line strings incorrectly.
430
488
  * The main issue is list items that span multiple lines without proper quoting:
@@ -492,6 +550,19 @@ function fixYamlMultilineStrings(yaml) {
492
550
  }
493
551
  return result.join("\n");
494
552
  }
553
+ /**
554
+ * Robustly parses YAML content, applying automatic fixes for common
555
+ * LLM-generated YAML issues (multiline strings, embedded quotes).
556
+ */
557
+ export function robustYamlParse(content) {
558
+ try {
559
+ return YAML.parse(content);
560
+ }
561
+ catch {
562
+ const fixed = fixYamlEmbeddedQuotes(fixYamlMultilineStrings(content));
563
+ return YAML.parse(fixed);
564
+ }
565
+ }
495
566
  /**
496
567
  * Common wrapper keys that LLMs use to wrap PRD arrays.
497
568
  * If parsed content is an object with one of these keys containing an array,
@@ -524,14 +595,13 @@ function unwrapPrdContent(content) {
524
595
  export function readYamlPrdFile(prdPath) {
525
596
  try {
526
597
  const raw = readFileSync(prdPath, "utf-8");
527
- // Try parsing as-is first
598
+ // Try parsing as-is first, then with fixes for common LLM issues
528
599
  let content;
529
600
  try {
530
601
  content = YAML.parse(raw);
531
602
  }
532
603
  catch {
533
- // Try fixing common issues and parse again
534
- const fixed = fixYamlMultilineStrings(raw);
604
+ const fixed = fixYamlEmbeddedQuotes(fixYamlMultilineStrings(raw));
535
605
  content = YAML.parse(fixed);
536
606
  }
537
607
  // Unwrap if wrapped in common object structure
@@ -556,13 +626,12 @@ export function readPrdFile(prdPath) {
556
626
  // Parse based on file extension
557
627
  let content;
558
628
  if (ext === ".yaml" || ext === ".yml") {
559
- // Try parsing as-is first
629
+ // Try parsing as-is first, then with fixes for common LLM issues
560
630
  try {
561
631
  content = YAML.parse(raw);
562
632
  }
563
633
  catch {
564
- // Try fixing common issues and parse again
565
- const fixed = fixYamlMultilineStrings(raw);
634
+ const fixed = fixYamlEmbeddedQuotes(fixYamlMultilineStrings(raw));
566
635
  content = YAML.parse(fixed);
567
636
  // Unwrap if wrapped in common object structure
568
637
  content = unwrapPrdContent(content);
@@ -0,0 +1,36 @@
1
+ /**
2
+ * GitProvider — implements VcsProvider using git commands.
3
+ */
4
+ import type { VcsProvider, VcsExecOptions, MergeResult, DiffPattern } from "./vcs.js";
5
+ export declare class GitProvider implements VcsProvider {
6
+ readonly type: "git";
7
+ hasCommits(opts?: VcsExecOptions): boolean;
8
+ getCurrentBranch(opts?: VcsExecOptions): string;
9
+ branchExists(name: string, opts?: VcsExecOptions): boolean;
10
+ status(opts?: VcsExecOptions): string;
11
+ createBranch(name: string, opts?: VcsExecOptions): void;
12
+ deleteBranch(name: string, opts?: VcsExecOptions): void;
13
+ diff(opts?: VcsExecOptions): string;
14
+ diffStaged(opts?: VcsExecOptions): string;
15
+ diffAll(opts?: VcsExecOptions): string;
16
+ showCommit(revisionOffset: number, opts?: VcsExecOptions): string;
17
+ commit(message: string, opts?: VcsExecOptions): void;
18
+ merge(branchName: string, opts?: VcsExecOptions): MergeResult;
19
+ mergeAbort(opts?: VcsExecOptions): void;
20
+ getConflictingFiles(opts?: VcsExecOptions): string[];
21
+ getRemote(opts?: VcsExecOptions): string;
22
+ hasUpstream(branchName: string, opts?: VcsExecOptions): boolean;
23
+ push(remote: string, branchName: string, opts?: VcsExecOptions): void;
24
+ logBetween(base: string, head: string, opts?: VcsExecOptions): string;
25
+ createWorkspace(path: string, branch: string, createBranch: boolean, opts?: VcsExecOptions): void;
26
+ removeWorkspace(path: string, force?: boolean, opts?: VcsExecOptions): void;
27
+ getDockerConfigCommands(dockerConfig?: {
28
+ git?: {
29
+ name?: string;
30
+ email?: string;
31
+ };
32
+ }): string[];
33
+ getDockerInstallSnippet(): string;
34
+ getCommitInstruction(): string;
35
+ getDiffPatterns(): DiffPattern[];
36
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * GitProvider — implements VcsProvider using git commands.
3
+ */
4
+ import { execSync } from "child_process";
5
+ export class GitProvider {
6
+ type = "git";
7
+ hasCommits(opts) {
8
+ try {
9
+ execSync("git rev-parse HEAD", { stdio: "pipe", ...opts });
10
+ return true;
11
+ }
12
+ catch {
13
+ return false;
14
+ }
15
+ }
16
+ getCurrentBranch(opts) {
17
+ try {
18
+ return execSync("git rev-parse --abbrev-ref HEAD", {
19
+ encoding: "utf-8",
20
+ ...opts,
21
+ }).trim();
22
+ }
23
+ catch {
24
+ return "main";
25
+ }
26
+ }
27
+ branchExists(name, opts) {
28
+ try {
29
+ execSync(`git rev-parse --verify "${name}"`, { stdio: "pipe", ...opts });
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ status(opts) {
37
+ return execSync("git status --porcelain", {
38
+ encoding: "utf-8",
39
+ ...opts,
40
+ });
41
+ }
42
+ createBranch(name, opts) {
43
+ execSync(`git checkout -b "${name}"`, { stdio: "pipe", ...opts });
44
+ }
45
+ deleteBranch(name, opts) {
46
+ execSync(`git branch -D "${name}"`, { stdio: "pipe", ...opts });
47
+ }
48
+ diff(opts) {
49
+ return execSync("git diff", {
50
+ encoding: "utf-8",
51
+ maxBuffer: 1024 * 1024,
52
+ timeout: 10000,
53
+ ...opts,
54
+ }).trim();
55
+ }
56
+ diffStaged(opts) {
57
+ return execSync("git diff --cached", {
58
+ encoding: "utf-8",
59
+ maxBuffer: 1024 * 1024,
60
+ timeout: 10000,
61
+ ...opts,
62
+ }).trim();
63
+ }
64
+ diffAll(opts) {
65
+ return execSync("git diff HEAD", {
66
+ encoding: "utf-8",
67
+ maxBuffer: 1024 * 1024,
68
+ timeout: 10000,
69
+ ...opts,
70
+ }).trim();
71
+ }
72
+ showCommit(revisionOffset, opts) {
73
+ const ref = revisionOffset === 0 ? "HEAD" : `HEAD~${revisionOffset}`;
74
+ return execSync(`git show ${ref} --stat --patch`, {
75
+ encoding: "utf-8",
76
+ maxBuffer: 1024 * 1024,
77
+ timeout: 10000,
78
+ ...opts,
79
+ }).trim();
80
+ }
81
+ commit(message, opts) {
82
+ execSync(`git add -A && git commit -m "${message.replace(/"/g, '\\"')}"`, {
83
+ stdio: "pipe",
84
+ ...opts,
85
+ });
86
+ }
87
+ merge(branchName, opts) {
88
+ try {
89
+ execSync(`git merge "${branchName}" --no-edit`, { stdio: "pipe", ...opts });
90
+ return { success: true };
91
+ }
92
+ catch {
93
+ const conflictingFiles = this.getConflictingFiles(opts);
94
+ return { success: false, conflictingFiles };
95
+ }
96
+ }
97
+ mergeAbort(opts) {
98
+ try {
99
+ execSync("git merge --abort", { stdio: "pipe", ...opts });
100
+ }
101
+ catch {
102
+ // Ignore if nothing to abort
103
+ }
104
+ }
105
+ getConflictingFiles(opts) {
106
+ try {
107
+ const status = execSync("git status --porcelain", { encoding: "utf-8", ...opts });
108
+ return status
109
+ .split("\n")
110
+ .filter((line) => line.startsWith("UU") ||
111
+ line.startsWith("AA") ||
112
+ line.startsWith("DD") ||
113
+ line.startsWith("AU") ||
114
+ line.startsWith("UA") ||
115
+ line.startsWith("DU") ||
116
+ line.startsWith("UD"))
117
+ .map((line) => line.substring(3).trim());
118
+ }
119
+ catch {
120
+ return [];
121
+ }
122
+ }
123
+ getRemote(opts) {
124
+ return execSync("git remote", { encoding: "utf-8", ...opts }).trim().split("\n")[0];
125
+ }
126
+ hasUpstream(branchName, opts) {
127
+ try {
128
+ execSync(`git rev-parse --abbrev-ref "${branchName}@{upstream}"`, {
129
+ stdio: "pipe",
130
+ ...opts,
131
+ });
132
+ return true;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ push(remote, branchName, opts) {
139
+ execSync(`git push -u "${remote}" "${branchName}"`, { stdio: "pipe", ...opts });
140
+ }
141
+ logBetween(base, head, opts) {
142
+ return execSync(`git log "${base}..${head}" --oneline --no-decorate`, {
143
+ encoding: "utf-8",
144
+ ...opts,
145
+ }).trim();
146
+ }
147
+ createWorkspace(path, branch, createBranch, opts) {
148
+ if (createBranch) {
149
+ execSync(`git worktree add -b "${branch}" "${path}"`, { stdio: "pipe", ...opts });
150
+ }
151
+ else {
152
+ execSync(`git worktree add "${path}" "${branch}"`, { stdio: "pipe", ...opts });
153
+ }
154
+ }
155
+ removeWorkspace(path, force, opts) {
156
+ const forceFlag = force ? " --force" : "";
157
+ execSync(`git worktree remove "${path}"${forceFlag}`, { stdio: "pipe", ...opts });
158
+ }
159
+ getDockerConfigCommands(dockerConfig) {
160
+ const commands = [`git config --global init.defaultBranch main`];
161
+ if (dockerConfig?.git?.name) {
162
+ commands.push(`git config --global user.name "${dockerConfig.git.name}"`);
163
+ }
164
+ if (dockerConfig?.git?.email) {
165
+ commands.push(`git config --global user.email "${dockerConfig.git.email}"`);
166
+ }
167
+ return commands;
168
+ }
169
+ getDockerInstallSnippet() {
170
+ // Git is already installed in the base Docker image
171
+ return "";
172
+ }
173
+ getCommitInstruction() {
174
+ return "Create a git commit with a descriptive message for this feature";
175
+ }
176
+ getDiffPatterns() {
177
+ return [
178
+ { pattern: /^(diff|changes)$/i, command: "git diff", description: "unstaged changes" },
179
+ { pattern: /^staged$/i, command: "git diff --cached", description: "staged changes" },
180
+ {
181
+ pattern: /^(last|last\s*commit)$/i,
182
+ command: "git show HEAD --stat --patch",
183
+ description: "last commit",
184
+ },
185
+ {
186
+ pattern: /^HEAD~(\d+)$/i,
187
+ command: "git show HEAD~$1 --stat --patch",
188
+ description: "commit",
189
+ },
190
+ { pattern: /^all$/i, command: "git diff HEAD", description: "all uncommitted changes" },
191
+ ];
192
+ }
193
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * JjProvider — implements VcsProvider using Jujutsu (jj) commands.
3
+ */
4
+ import type { VcsProvider, VcsExecOptions, MergeResult, DiffPattern } from "./vcs.js";
5
+ export declare class JjProvider implements VcsProvider {
6
+ readonly type: "jj";
7
+ hasCommits(opts?: VcsExecOptions): boolean;
8
+ getCurrentBranch(opts?: VcsExecOptions): string;
9
+ branchExists(name: string, opts?: VcsExecOptions): boolean;
10
+ status(opts?: VcsExecOptions): string;
11
+ createBranch(name: string, opts?: VcsExecOptions): void;
12
+ deleteBranch(name: string, opts?: VcsExecOptions): void;
13
+ diff(opts?: VcsExecOptions): string;
14
+ diffStaged(_opts?: VcsExecOptions): string;
15
+ diffAll(opts?: VcsExecOptions): string;
16
+ showCommit(revisionOffset: number, opts?: VcsExecOptions): string;
17
+ commit(message: string, opts?: VcsExecOptions): void;
18
+ merge(branchName: string, opts?: VcsExecOptions): MergeResult;
19
+ mergeAbort(opts?: VcsExecOptions): void;
20
+ getConflictingFiles(opts?: VcsExecOptions): string[];
21
+ getRemote(opts?: VcsExecOptions): string;
22
+ hasUpstream(branchName: string, opts?: VcsExecOptions): boolean;
23
+ push(remote: string, branchName: string, opts?: VcsExecOptions): void;
24
+ logBetween(base: string, head: string, opts?: VcsExecOptions): string;
25
+ createWorkspace(path: string, _branch: string, _createBranch: boolean, opts?: VcsExecOptions): void;
26
+ removeWorkspace(path: string, _force?: boolean, opts?: VcsExecOptions): void;
27
+ getDockerConfigCommands(dockerConfig?: {
28
+ git?: {
29
+ name?: string;
30
+ email?: string;
31
+ };
32
+ }): string[];
33
+ getDockerInstallSnippet(): string;
34
+ getCommitInstruction(): string;
35
+ getDiffPatterns(): DiffPattern[];
36
+ }