ralph-cli-sandboxed 0.6.0 → 0.6.2

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.
@@ -4,9 +4,9 @@
4
4
  */
5
5
  import { existsSync, readFileSync, writeFileSync, watch } from "fs";
6
6
  import { join, basename, extname } from "path";
7
- import { spawn } from "child_process";
7
+ import { execSync, spawn } from "child_process";
8
8
  import YAML from "yaml";
9
- import { loadConfig, getRalphDir, isRunningInContainer, getPrdFiles } from "../utils/config.js";
9
+ import { loadConfig, getRalphDir, isRunningInContainer, getPrdFiles, loadBranchState, getProjectName as getConfigProjectName } from "../utils/config.js";
10
10
  import { createTelegramClient } from "../providers/telegram.js";
11
11
  import { createSlackClient } from "../providers/slack.js";
12
12
  import { createDiscordClient } from "../providers/discord.js";
@@ -163,6 +163,290 @@ function addPrdTask(description) {
163
163
  return false;
164
164
  }
165
165
  }
166
+ /**
167
+ * Get the current git branch in the project directory.
168
+ */
169
+ function getBaseBranch() {
170
+ try {
171
+ return execSync("git rev-parse --abbrev-ref HEAD", {
172
+ encoding: "utf-8",
173
+ cwd: process.cwd(),
174
+ }).trim();
175
+ }
176
+ catch {
177
+ return "main";
178
+ }
179
+ }
180
+ /**
181
+ * Check if a git branch exists.
182
+ */
183
+ function branchExists(branch) {
184
+ try {
185
+ execSync(`git rev-parse --verify "${branch}"`, {
186
+ stdio: "pipe",
187
+ cwd: process.cwd(),
188
+ });
189
+ return true;
190
+ }
191
+ catch {
192
+ return false;
193
+ }
194
+ }
195
+ /**
196
+ * Convert branch name to worktree directory name.
197
+ */
198
+ function branchToWorktreeName(branch) {
199
+ const projectName = getConfigProjectName();
200
+ return `${projectName}_${branch.replace(/\//g, "-")}`;
201
+ }
202
+ /**
203
+ * Handle /branch list — show branches from PRD grouped by branch field.
204
+ */
205
+ async function handleBranchList(chatId, client, state) {
206
+ const prdFiles = getPrdFiles();
207
+ if (prdFiles.none || !prdFiles.primary) {
208
+ await client.sendMessage(chatId, `${state.projectName}: No PRD file found.`);
209
+ return;
210
+ }
211
+ const content = readFileSync(prdFiles.primary, "utf-8");
212
+ const items = parsePrdContent(prdFiles.primary, content);
213
+ if (!Array.isArray(items) || items.length === 0) {
214
+ await client.sendMessage(chatId, `${state.projectName}: No PRD items found.`);
215
+ return;
216
+ }
217
+ const activeBranch = loadBranchState();
218
+ // Group items by branch
219
+ const branchGroups = new Map();
220
+ const noBranchItems = [];
221
+ for (const item of items) {
222
+ if (item.branch) {
223
+ const group = branchGroups.get(item.branch) || [];
224
+ group.push(item);
225
+ branchGroups.set(item.branch, group);
226
+ }
227
+ else {
228
+ noBranchItems.push(item);
229
+ }
230
+ }
231
+ if (branchGroups.size === 0 && noBranchItems.length === 0) {
232
+ await client.sendMessage(chatId, `${state.projectName}: No PRD items found.`);
233
+ return;
234
+ }
235
+ const lines = [`${state.projectName}: Branches\n`];
236
+ const sortedBranches = [...branchGroups.keys()].sort();
237
+ for (const branchName of sortedBranches) {
238
+ const branchItems = branchGroups.get(branchName);
239
+ const passing = branchItems.filter((e) => e.passes === true).length;
240
+ const total = branchItems.length;
241
+ const allPassing = passing === total;
242
+ const isActive = activeBranch?.currentBranch === branchName;
243
+ const icon = allPassing ? "[OK]" : "[ ]";
244
+ const active = isActive ? " << active" : "";
245
+ lines.push(` ${icon} ${branchName} ${passing}/${total}${active}`);
246
+ }
247
+ if (noBranchItems.length > 0) {
248
+ const passing = noBranchItems.filter((e) => e.passes === true).length;
249
+ const total = noBranchItems.length;
250
+ const icon = passing === total ? "[OK]" : "[ ]";
251
+ lines.push(` ${icon} (no branch) ${passing}/${total}`);
252
+ }
253
+ await client.sendMessage(chatId, lines.join("\n"));
254
+ }
255
+ /**
256
+ * Handle /branch pr <name> — add a PRD item to create a pull request.
257
+ */
258
+ async function handleBranchPr(args, chatId, client, state) {
259
+ const branchName = args[0];
260
+ if (!branchName) {
261
+ const usage = client.provider === "slack"
262
+ ? "/ralph branch pr <branch-name>"
263
+ : "/branch pr <branch-name>";
264
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
265
+ return;
266
+ }
267
+ if (!branchExists(branchName)) {
268
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
269
+ return;
270
+ }
271
+ const baseBranch = getBaseBranch();
272
+ const prdFiles = getPrdFiles();
273
+ if (prdFiles.none || !prdFiles.primary) {
274
+ await client.sendMessage(chatId, `${state.projectName}: No PRD file found.`);
275
+ return;
276
+ }
277
+ const content = readFileSync(prdFiles.primary, "utf-8");
278
+ const items = parsePrdContent(prdFiles.primary, content);
279
+ if (!Array.isArray(items)) {
280
+ await client.sendMessage(chatId, `${state.projectName}: Failed to parse PRD file.`);
281
+ return;
282
+ }
283
+ items.push({
284
+ category: "feature",
285
+ description: `Create a pull request from \`${branchName}\` into \`${baseBranch}\``,
286
+ steps: [
287
+ `Ensure all changes on \`${branchName}\` are committed`,
288
+ `Push \`${branchName}\` to the remote if not already pushed`,
289
+ `Create a pull request from \`${branchName}\` into \`${baseBranch}\` using the appropriate tool (e.g. gh pr create)`,
290
+ "Include a descriptive title and summary of the changes in the PR",
291
+ ],
292
+ passes: false,
293
+ branch: branchName,
294
+ });
295
+ const ext = extname(prdFiles.primary).toLowerCase();
296
+ if (ext === ".yaml" || ext === ".yml") {
297
+ writeFileSync(prdFiles.primary, YAML.stringify(items));
298
+ }
299
+ else {
300
+ writeFileSync(prdFiles.primary, JSON.stringify(items, null, 2) + "\n");
301
+ }
302
+ await client.sendMessage(chatId, `${state.projectName}: Added PRD entry: Create PR for ${branchName} -> ${baseBranch}`);
303
+ }
304
+ /**
305
+ * Handle /branch merge <name> — merge branch into base branch.
306
+ * Skips confirmation (user explicitly typed the command in chat).
307
+ */
308
+ async function handleBranchMerge(args, chatId, client, state) {
309
+ const branchName = args[0];
310
+ if (!branchName) {
311
+ const usage = client.provider === "slack"
312
+ ? "/ralph branch merge <branch-name>"
313
+ : "/branch merge <branch-name>";
314
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
315
+ return;
316
+ }
317
+ if (!branchExists(branchName)) {
318
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
319
+ return;
320
+ }
321
+ const baseBranch = getBaseBranch();
322
+ const cwd = process.cwd();
323
+ try {
324
+ execSync(`git merge "${branchName}" --no-edit`, { stdio: "pipe", cwd });
325
+ await client.sendMessage(chatId, `${state.projectName}: Merged "${branchName}" into "${baseBranch}".`);
326
+ }
327
+ catch {
328
+ // Check for merge conflicts
329
+ let conflictingFiles = [];
330
+ try {
331
+ const status = execSync("git status --porcelain", { encoding: "utf-8", cwd });
332
+ conflictingFiles = status
333
+ .split("\n")
334
+ .filter((line) => line.startsWith("UU") || line.startsWith("AA") || line.startsWith("DD") ||
335
+ line.startsWith("AU") || line.startsWith("UA") || line.startsWith("DU") ||
336
+ line.startsWith("UD"))
337
+ .map((line) => line.substring(3).trim());
338
+ }
339
+ catch {
340
+ // Ignore status errors
341
+ }
342
+ if (conflictingFiles.length > 0) {
343
+ // Abort the merge
344
+ try {
345
+ execSync("git merge --abort", { stdio: "pipe", cwd });
346
+ }
347
+ catch {
348
+ // Ignore abort errors
349
+ }
350
+ await client.sendMessage(chatId, `${state.projectName}: Merge conflict! Conflicting files:\n${conflictingFiles.join("\n")}\nMerge aborted.`);
351
+ }
352
+ else {
353
+ try {
354
+ execSync("git merge --abort", { stdio: "pipe", cwd });
355
+ }
356
+ catch {
357
+ // Ignore
358
+ }
359
+ await client.sendMessage(chatId, `${state.projectName}: Merge of "${branchName}" failed. Merge aborted.`);
360
+ }
361
+ return;
362
+ }
363
+ // Clean up worktree if it exists
364
+ const dirName = branchToWorktreeName(branchName);
365
+ const config = loadConfig();
366
+ const worktreesPath = config.docker?.worktreesPath;
367
+ if (worktreesPath) {
368
+ const worktreePath = join(worktreesPath, dirName);
369
+ if (existsSync(worktreePath)) {
370
+ try {
371
+ execSync(`git worktree remove "${worktreePath}"`, { stdio: "pipe", cwd });
372
+ }
373
+ catch {
374
+ // Non-critical, ignore
375
+ }
376
+ }
377
+ }
378
+ }
379
+ /**
380
+ * Handle /branch delete <name> — delete branch, worktree, and untag PRD items.
381
+ * Skips confirmation (user explicitly typed the command in chat).
382
+ */
383
+ async function handleBranchDelete(args, chatId, client, state) {
384
+ const branchName = args[0];
385
+ if (!branchName) {
386
+ const usage = client.provider === "slack"
387
+ ? "/ralph branch delete <branch-name>"
388
+ : "/branch delete <branch-name>";
389
+ await client.sendMessage(chatId, `${state.projectName}: Usage: ${usage}`);
390
+ return;
391
+ }
392
+ if (!branchExists(branchName)) {
393
+ await client.sendMessage(chatId, `${state.projectName}: Branch "${branchName}" does not exist.`);
394
+ return;
395
+ }
396
+ const cwd = process.cwd();
397
+ const results = [];
398
+ // Step 1: Remove worktree if it exists
399
+ const dirName = branchToWorktreeName(branchName);
400
+ const config = loadConfig();
401
+ const worktreesPath = config.docker?.worktreesPath;
402
+ if (worktreesPath) {
403
+ const worktreePath = join(worktreesPath, dirName);
404
+ if (existsSync(worktreePath)) {
405
+ try {
406
+ execSync(`git worktree remove "${worktreePath}" --force`, { stdio: "pipe", cwd });
407
+ results.push("Worktree removed.");
408
+ }
409
+ catch {
410
+ results.push("Warning: Could not remove worktree.");
411
+ }
412
+ }
413
+ }
414
+ // Step 2: Delete the git branch
415
+ try {
416
+ execSync(`git branch -D "${branchName}"`, { stdio: "pipe", cwd });
417
+ results.push("Branch deleted.");
418
+ }
419
+ catch {
420
+ results.push("Warning: Could not delete git branch.");
421
+ }
422
+ // Step 3: Remove branch tag from PRD items
423
+ const prdFiles = getPrdFiles();
424
+ if (!prdFiles.none && prdFiles.primary) {
425
+ const content = readFileSync(prdFiles.primary, "utf-8");
426
+ const items = parsePrdContent(prdFiles.primary, content);
427
+ if (Array.isArray(items)) {
428
+ const taggedCount = items.filter((e) => e.branch === branchName).length;
429
+ if (taggedCount > 0) {
430
+ const updatedItems = items.map((item) => {
431
+ if (item.branch === branchName) {
432
+ const { branch: _, ...rest } = item;
433
+ return rest;
434
+ }
435
+ return item;
436
+ });
437
+ const ext = extname(prdFiles.primary).toLowerCase();
438
+ if (ext === ".yaml" || ext === ".yml") {
439
+ writeFileSync(prdFiles.primary, YAML.stringify(updatedItems));
440
+ }
441
+ else {
442
+ writeFileSync(prdFiles.primary, JSON.stringify(updatedItems, null, 2) + "\n");
443
+ }
444
+ results.push(`${taggedCount} PRD item(s) untagged.`);
445
+ }
446
+ }
447
+ }
448
+ await client.sendMessage(chatId, `${state.projectName}: Deleted "${branchName}". ${results.join(" ")}`);
449
+ }
166
450
  /**
167
451
  * Execute a shell command and return the output.
168
452
  */
@@ -428,6 +712,37 @@ async function handleCommand(command, client, config, state, debug) {
428
712
  }
429
713
  break;
430
714
  }
715
+ case "branch": {
716
+ const subCmd = args[0]?.toLowerCase();
717
+ const branchArgs = args.slice(1);
718
+ switch (subCmd) {
719
+ case "list":
720
+ await handleBranchList(chatId, client, state);
721
+ break;
722
+ case "pr":
723
+ await handleBranchPr(branchArgs, chatId, client, state);
724
+ break;
725
+ case "merge":
726
+ await handleBranchMerge(branchArgs, chatId, client, state);
727
+ break;
728
+ case "delete":
729
+ await handleBranchDelete(branchArgs, chatId, client, state);
730
+ break;
731
+ default: {
732
+ const usage = client.provider === "slack"
733
+ ? `/ralph branch list - List branches
734
+ /ralph branch pr <name> - Add PRD item to create PR
735
+ /ralph branch merge <name> - Merge branch into base
736
+ /ralph branch delete <name> - Delete branch and worktree`
737
+ : `/branch list - List branches
738
+ /branch pr <name> - Add PRD item to create PR
739
+ /branch merge <name> - Merge branch into base
740
+ /branch delete <name> - Delete branch and worktree`;
741
+ await client.sendMessage(chatId, `${state.projectName}: ${usage}`);
742
+ }
743
+ }
744
+ break;
745
+ }
431
746
  case "help": {
432
747
  const isSlack = client.provider === "slack";
433
748
  const helpText = isSlack
@@ -438,6 +753,7 @@ async function handleCommand(command, client, config, state, debug) {
438
753
  /ralph add [desc] - Add task
439
754
  /ralph exec [cmd] - Shell command
440
755
  /ralph action [name] - Run action
756
+ /ralph branch ... - Manage branches
441
757
  /ralph <prompt> - Run Claude Code`
442
758
  : `/help - This help
443
759
  /status - PRD progress
@@ -446,6 +762,7 @@ async function handleCommand(command, client, config, state, debug) {
446
762
  /add [desc] - Add task
447
763
  /exec [cmd] - Shell command
448
764
  /action [name] - Run action
765
+ /branch ... - Manage branches
449
766
  /claude [prompt] - Run Claude Code`;
450
767
  await client.sendMessage(chatId, helpText);
451
768
  break;
@@ -640,6 +957,7 @@ async function startChat(config, debug) {
640
957
  console.log(" /ralph add ... - Add new task to PRD");
641
958
  console.log(" /ralph exec ... - Execute shell command");
642
959
  console.log(" /ralph action ... - Run daemon action");
960
+ console.log(" /ralph branch ... - Manage branches");
643
961
  console.log(" /ralph <prompt> - Run Claude Code with prompt");
644
962
  }
645
963
  else {
@@ -187,31 +187,36 @@ RUN sh -c "$(wget -O- https://github.com/deluan/zsh-in-docker/releases/download/
187
187
  -a "export HISTFILE=/commandhistory/.zsh_history" \\
188
188
  -a 'alias ll="ls -la"'
189
189
 
190
- # Set custom prompt for node user (after oh-my-zsh to avoid override)
190
+ # Copy oh-my-zsh config to node user (after oh-my-zsh to avoid override)
191
191
  RUN cp -r /root/.oh-my-zsh /home/node/.oh-my-zsh && chown -R node:node /home/node/.oh-my-zsh && \\
192
192
  cp /root/.zshrc /home/node/.zshrc && chown node:node /home/node/.zshrc && \\
193
- sed -i 's|/root/.oh-my-zsh|/home/node/.oh-my-zsh|g' /home/node/.zshrc && \\
194
- echo 'PROMPT="%K{yellow}%F{black}[ralph]%f%k%K{yellow}%F{black}%d%f%k\\$ "' >> /home/node/.zshrc && \\
195
- echo '' >> /home/node/.zshrc && \\
196
- echo '# Ralph ASCII art banner' >> /home/node/.zshrc && \\
197
- echo 'if [ -z "$RALPH_BANNER_SHOWN" ]; then' >> /home/node/.zshrc && \\
198
- echo ' export RALPH_BANNER_SHOWN=1' >> /home/node/.zshrc && \\
199
- echo ' echo ""' >> /home/node/.zshrc && \\
200
- echo ' echo "\\033[38;2;255;245;157m██████╗ █████╗ ██╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗\\033[0m"' >> /home/node/.zshrc && \\
201
- echo ' echo "\\033[38;2;255;238;88m██╔══██╗██╔══██╗██║ ██╔══██╗██║ ██║ ██╔════╝██║ ██║\\033[0m"' >> /home/node/.zshrc && \\
202
- echo ' echo "\\033[38;2;255;235;59m██████╔╝███████║██║ ██████╔╝███████║ ██║ ██║ ██║ sandboxed\\033[0m"' >> /home/node/.zshrc && \\
203
- echo ' echo "\\033[38;2;253;216;53m██╔══██╗██╔══██║██║ ██╔═══╝ ██╔══██║ ██║ ██║ ██║\\033[0m"' >> /home/node/.zshrc && \\
204
- echo ' echo "\\033[38;2;251;192;45m██║ ██║██║ ██║███████╗██║ ██║ ██║ ╚██████╗███████╗██║\\033[0m"' >> /home/node/.zshrc && \\
205
- echo ' echo "\\033[38;2;249;168;37m╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝\\033[0m"' >> /home/node/.zshrc && \\
206
- echo ' RALPH_VERSION=$(ralph --version 2>/dev/null | head -1 || echo "unknown")' >> /home/node/.zshrc && \\
207
- echo ' echo "\\033[38;5;248mv$RALPH_VERSION\\033[0m"' >> /home/node/.zshrc && \\
208
- echo ' echo ""' >> /home/node/.zshrc && \\
209
- echo 'fi' >> /home/node/.zshrc
193
+ sed -i 's|/root/.oh-my-zsh|/home/node/.oh-my-zsh|g' /home/node/.zshrc
194
+
195
+ # Set custom prompt and Ralph ASCII art banner
196
+ RUN cat >> /home/node/.zshrc <<'RALPH_BANNER'
197
+ PROMPT="%K{yellow}%F{black}[ralph]%f%k%K{yellow}%F{black}%d%f%k\\$ "
198
+
199
+ # Ralph ASCII art banner
200
+ if [ -z "$RALPH_BANNER_SHOWN" ]; then
201
+ export RALPH_BANNER_SHOWN=1
202
+ echo ""
203
+ echo "\\033[38;2;255;245;157m██████╗ █████╗ ██╗ ██████╗ ██╗ ██╗ ██████╗██╗ ██╗\\033[0m"
204
+ echo "\\033[38;2;255;238;88m██╔══██╗██╔══██╗██║ ██╔══██╗██║ ██║ ██╔════╝██║ ██║\\033[0m"
205
+ echo "\\033[38;2;255;235;59m██████╔╝███████║██║ ██████╔╝███████║ ██║ ██║ ██║ sandboxed\\033[0m"
206
+ echo "\\033[38;2;253;216;53m██╔══██╗██╔══██║██║ ██╔═══╝ ██╔══██║ ██║ ██║ ██║\\033[0m"
207
+ echo "\\033[38;2;251;192;45m██║ ██║██║ ██║███████╗██║ ██║ ██║ ╚██████╗███████╗██║\\033[0m"
208
+ echo "\\033[38;2;249;168;37m╚═╝ ╚═╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ ╚═╝ ╚═════╝╚══════╝╚═╝\\033[0m"
209
+ RALPH_VERSION=$(ralph --version 2>/dev/null | head -1 || echo "unknown")
210
+ echo "\\033[38;5;248mv$RALPH_VERSION\\033[0m"
211
+ echo ""
212
+ fi
213
+ RALPH_BANNER
210
214
 
211
215
  ${cliSnippet}
212
216
 
213
217
  # Install ralph-cli-sandboxed from npm registry
214
218
  RUN npm install -g ralph-cli-sandboxed
219
+ RUN ralph logo
215
220
  ${languageSnippet}
216
221
  # Setup sudo only for firewall script (no general sudo for security)
217
222
  RUN echo "node ALL=(ALL) NOPASSWD: /usr/local/bin/init-firewall.sh" >> /etc/sudoers.d/node-firewall
@@ -483,11 +483,11 @@ function validateAndRecoverPrd(prdPath, validPrd) {
483
483
  }
484
484
  // If we can't even parse the JSON, restore from valid copy (with new items if we found any)
485
485
  if (!parsed) {
486
- console.log("\n\x1b[33mWarning: PRD corrupted (invalid JSON) - restored from memory.\x1b[0m");
486
+ console.log("\nNote: PRD corrupted (invalid JSON) - restored from memory.");
487
487
  const mergedPrd = [...validPrd, ...newItems];
488
488
  writePrd(prdPath, mergedPrd);
489
489
  if (newItems.length > 0) {
490
- console.log(`\x1b[32mPreserved ${newItems.length} newly-added item(s).\x1b[0m`);
490
+ console.log(`Preserved ${newItems.length} newly-added item(s).`);
491
491
  }
492
492
  return { recovered: true, itemsUpdated: 0, newItemsPreserved: newItems.length };
493
493
  }
@@ -498,23 +498,23 @@ function validateAndRecoverPrd(prdPath, validPrd) {
498
498
  return { recovered: false, itemsUpdated: 0, newItemsPreserved: 0 };
499
499
  }
500
500
  // PRD is corrupted - use smart merge to extract passes flags
501
- console.log("\n\x1b[33mWarning: PRD format corrupted by LLM - recovering...\x1b[0m");
501
+ console.log("\nNote: PRD format corrupted by LLM - recovering...");
502
502
  const mergeResult = smartMerge(validPrd, parsed.content);
503
503
  // Add any newly-added items
504
504
  const mergedPrd = [...mergeResult.merged, ...newItems];
505
505
  // Write the valid structure back (with new items)
506
506
  writePrd(prdPath, mergedPrd);
507
507
  if (mergeResult.itemsUpdated > 0) {
508
- console.log(`\x1b[32mRecovered: merged ${mergeResult.itemsUpdated} passes flag(s) into valid PRD structure.\x1b[0m`);
508
+ console.log(`Recovered: merged ${mergeResult.itemsUpdated} passes flag(s) into valid PRD structure.`);
509
509
  }
510
510
  else {
511
- console.log("\x1b[32mRecovered: restored valid PRD structure.\x1b[0m");
511
+ console.log("Recovered: restored valid PRD structure.");
512
512
  }
513
513
  if (newItems.length > 0) {
514
- console.log(`\x1b[32mPreserved ${newItems.length} newly-added item(s).\x1b[0m`);
514
+ console.log(`Preserved ${newItems.length} newly-added item(s).`);
515
515
  }
516
516
  if (mergeResult.warnings.length > 0) {
517
- mergeResult.warnings.forEach((w) => console.log(` \x1b[33m${w}\x1b[0m`));
517
+ mergeResult.warnings.forEach((w) => console.log(` ${w}`));
518
518
  }
519
519
  return { recovered: true, itemsUpdated: mergeResult.itemsUpdated, newItemsPreserved: newItems.length };
520
520
  }
@@ -1034,9 +1034,19 @@ export async function run(args) {
1034
1034
  consecutiveFailures = 0;
1035
1035
  lastExitCode = 0;
1036
1036
  }
1037
- // Check for completion signal
1037
+ // Check for completion signal from the LLM.
1038
+ // The LLM only sees a subset of items (branch group or no-branch group),
1039
+ // so its COMPLETE signal means "this group is done", not "all PRD items are done".
1040
+ // We must verify the full PRD before treating this as a global completion.
1038
1041
  if (iterOutput.includes("<promise>COMPLETE</promise>")) {
1039
- if (loopMode) {
1042
+ const fullCounts = countPrdItems(paths.prd, category);
1043
+ if (fullCounts.incomplete > 0) {
1044
+ // There are still incomplete items in other groups — continue the loop
1045
+ if (debug) {
1046
+ console.log(`\n\x1b[90m[ralph] LLM signalled COMPLETE for current group, but ${fullCounts.incomplete} item(s) remain. Continuing...\x1b[0m`);
1047
+ }
1048
+ }
1049
+ else if (loopMode) {
1040
1050
  console.log("\n" + "=".repeat(50));
1041
1051
  console.log("PRD iteration complete. Waiting for new items...");
1042
1052
  console.log(`(Checking every ${POLL_INTERVAL_MS / 1000} seconds. Press Ctrl+C to stop)`);
@@ -1054,9 +1064,8 @@ export async function run(args) {
1054
1064
  else {
1055
1065
  console.log("\n" + "=".repeat(50));
1056
1066
  if (allMode) {
1057
- const counts = countPrdItems(paths.prd, category);
1058
1067
  console.log("PRD COMPLETE - All tasks finished!");
1059
- console.log(`Final Status: ${counts.complete}/${counts.total} complete`);
1068
+ console.log(`Final Status: ${fullCounts.complete}/${fullCounts.total} complete`);
1060
1069
  }
1061
1070
  else {
1062
1071
  console.log("PRD COMPLETE - All features implemented!");
@@ -475,7 +475,7 @@ export class SlackChatClient {
475
475
  // Handle the unified /ralph command
476
476
  // Subcommands: help, status, run, stop, add, exec, action
477
477
  // Anything else is treated as a prompt for Claude
478
- const knownSubcommands = ["help", "status", "run", "stop", "add", "exec", "action"];
478
+ const knownSubcommands = ["help", "status", "run", "stop", "add", "exec", "action", "branch"];
479
479
  if (this.debug) {
480
480
  console.log(`[slack] Registering command: /ralph`);
481
481
  }
@@ -38,6 +38,7 @@ export function parseCommand(text, message) {
38
38
  "start",
39
39
  "action",
40
40
  "claude",
41
+ "branch",
41
42
  ];
42
43
  // Check for slash command format: /command [args...]
43
44
  if (trimmed.startsWith("/")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-cli-sandboxed",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "AI-driven development automation CLI for Claude Code",
5
5
  "type": "module",
6
6
  "bin": {