ralph-cli-sandboxed 0.6.6 → 0.7.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.
Files changed (47) hide show
  1. package/LICENSE +191 -0
  2. package/README.md +22 -2
  3. package/dist/commands/ask.d.ts +6 -0
  4. package/dist/commands/ask.js +140 -0
  5. package/dist/commands/branch.js +8 -4
  6. package/dist/commands/chat.js +11 -8
  7. package/dist/commands/docker.js +104 -17
  8. package/dist/commands/fix-config.js +0 -41
  9. package/dist/commands/help.js +10 -0
  10. package/dist/commands/run.js +17 -13
  11. package/dist/config/languages.json +2 -1
  12. package/dist/config/responder-presets.json +10 -0
  13. package/dist/config/skills.json +8 -0
  14. package/dist/index.js +2 -0
  15. package/dist/mcp-server.d.ts +2 -0
  16. package/dist/mcp-server.js +366 -0
  17. package/dist/providers/telegram.js +1 -1
  18. package/dist/responders/claude-code-responder.js +24 -1
  19. package/dist/responders/cli-responder.js +1 -0
  20. package/dist/responders/llm-responder.js +1 -1
  21. package/dist/templates/macos-scripts.js +18 -18
  22. package/dist/templates/prompts.d.ts +1 -0
  23. package/dist/tui/components/JsonSnippetEditor.js +7 -7
  24. package/dist/tui/components/KeyValueEditor.js +5 -1
  25. package/dist/tui/components/LLMProvidersEditor.js +7 -9
  26. package/dist/tui/components/Preview.js +1 -1
  27. package/dist/tui/components/SectionNav.js +18 -2
  28. package/dist/utils/chat-client.js +1 -0
  29. package/dist/utils/config.d.ts +2 -0
  30. package/dist/utils/config.js +3 -1
  31. package/dist/utils/config.test.d.ts +1 -0
  32. package/dist/utils/config.test.js +424 -0
  33. package/dist/utils/notification.js +1 -1
  34. package/dist/utils/prd-validator.d.ts +1 -0
  35. package/dist/utils/prd-validator.js +32 -10
  36. package/dist/utils/prd-validator.test.d.ts +1 -0
  37. package/dist/utils/prd-validator.test.js +1095 -0
  38. package/dist/utils/responder-logger.d.ts +6 -5
  39. package/dist/utils/responder-presets.d.ts +1 -0
  40. package/dist/utils/responder-presets.js +2 -0
  41. package/dist/utils/responder.js +1 -1
  42. package/dist/utils/stream-json.test.d.ts +1 -0
  43. package/dist/utils/stream-json.test.js +1007 -0
  44. package/docs/DOCKER.md +14 -0
  45. package/docs/MCP.md +192 -0
  46. package/docs/PRD-GENERATOR.md +15 -0
  47. package/package.json +22 -18
@@ -186,47 +186,6 @@ function extractSectionFromCorrupt(content, section) {
186
186
  }
187
187
  return undefined;
188
188
  }
189
- /**
190
- * Attempts to recover valid sections from a corrupt config.
191
- */
192
- function recoverSections(corruptContent, parsedPartial) {
193
- const defaultConfig = getDefaultConfig();
194
- const recoveredConfig = {};
195
- const result = {
196
- recovered: [],
197
- reset: [],
198
- errors: [],
199
- };
200
- for (const section of CONFIG_SECTIONS) {
201
- let value = undefined;
202
- let source = "default";
203
- // First, try to get from parsed partial (if JSON was partially valid)
204
- if (parsedPartial && section in parsedPartial) {
205
- value = parsedPartial[section];
206
- source = "parsed";
207
- }
208
- // If not found or invalid, try regex extraction for simple string fields
209
- if ((value === undefined || !validateSection(section, value)) &&
210
- typeof corruptContent === "string") {
211
- const extracted = extractSectionFromCorrupt(corruptContent, section);
212
- if (extracted !== undefined && validateSection(section, extracted)) {
213
- value = extracted;
214
- source = "extracted";
215
- }
216
- }
217
- // Validate the value
218
- if (validateSection(section, value)) {
219
- recoveredConfig[section] = value;
220
- result.recovered.push(section);
221
- }
222
- else {
223
- // Use default value
224
- recoveredConfig[section] = defaultConfig[section];
225
- result.reset.push(section);
226
- }
227
- }
228
- return result;
229
- }
230
189
  /**
231
190
  * Creates a backup of the config file.
232
191
  */
@@ -20,6 +20,7 @@ COMMANDS:
20
20
  docker <sub> Manage Docker sandbox environment
21
21
  daemon <sub> Host daemon for sandbox-to-host communication
22
22
  notify [msg] Send notification to host from sandbox
23
+ ask <preset> msg Run a responder preset from the CLI
23
24
  action [name] Execute host actions from config.json
24
25
  chat <sub> Chat client integration (Telegram, etc.)
25
26
  slack <sub> Slack app setup and management
@@ -102,6 +103,12 @@ NOTIFY OPTIONS:
102
103
  --action, -a <name> Execute specific daemon action (default: notify)
103
104
  --debug, -d Show debug output
104
105
 
106
+ ASK OPTIONS:
107
+ <preset> Name of a built-in preset or config responder
108
+ <message...> Message to send to the responder
109
+ --list, -l List available presets and configured responders
110
+ --help, -h Show ask help message
111
+
105
112
  ACTION OPTIONS:
106
113
  [name] Name of the action to execute
107
114
  [args...] Arguments to pass to the action command
@@ -144,6 +151,9 @@ EXAMPLES:
144
151
  ralph chat start # Start Telegram chat daemon
145
152
  ralph chat test 123456 # Test chat connection
146
153
  ralph slack setup # Create new Slack app for this project
154
+ ralph ask --list # List available responder presets
155
+ ralph ask qa "What does the config loader do?" # Ask a question
156
+ ralph ask reviewer diff # Review current git diff
147
157
  ralph action --list # List available host actions
148
158
  ralph action build # Execute 'build' action on host
149
159
  ralph branch list # List all branches and their PRD status
@@ -1,5 +1,5 @@
1
- import { spawn, execSync } from "child_process";
2
- import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync, copyFileSync } from "fs";
1
+ import { spawn, execSync, execFileSync } from "child_process";
2
+ import { existsSync, readFileSync, writeFileSync, unlinkSync, appendFileSync, mkdirSync, copyFileSync, } from "fs";
3
3
  import { extname, join } from "path";
4
4
  import { checkFilesExist, loadConfig, loadPrompt, getPaths, getCliConfig, requireContainer, saveBranchState, loadBranchState, clearBranchState, getProjectName, } from "../utils/config.js";
5
5
  import { resolvePromptVariables, getCliProviders, GEMINI_MD } from "../templates/prompts.js";
@@ -62,10 +62,14 @@ function ensureWorktree(branch, worktreesBase) {
62
62
  return worktreePath;
63
63
  }
64
64
  console.log(`\x1b[90m[ralph] Creating worktree for branch "${branch}" at ${worktreePath}\x1b[0m`);
65
+ // Validate branch name to prevent command injection
66
+ if (!/^[a-zA-Z0-9_\-./]+$/.test(branch)) {
67
+ throw new Error(`Invalid branch name "${branch}": contains disallowed characters`);
68
+ }
65
69
  // Check if the branch already exists
66
70
  let branchExists = false;
67
71
  try {
68
- execSync(`git rev-parse --verify "${branch}"`, { stdio: "pipe" });
72
+ execFileSync("git", ["rev-parse", "--verify", branch], { stdio: "pipe" });
69
73
  branchExists = true;
70
74
  }
71
75
  catch {
@@ -73,11 +77,11 @@ function ensureWorktree(branch, worktreesBase) {
73
77
  }
74
78
  try {
75
79
  if (branchExists) {
76
- execSync(`git worktree add "${worktreePath}" "${branch}"`, { stdio: "pipe" });
80
+ execFileSync("git", ["worktree", "add", worktreePath, branch], { stdio: "pipe" });
77
81
  }
78
82
  else {
79
83
  // Create new branch from current HEAD
80
- execSync(`git worktree add -b "${branch}" "${worktreePath}"`, { stdio: "pipe" });
84
+ execFileSync("git", ["worktree", "add", "-b", branch, worktreePath], { stdio: "pipe" });
81
85
  }
82
86
  }
83
87
  catch (err) {
@@ -536,7 +540,11 @@ function validateAndRecoverPrd(prdPath, validPrd) {
536
540
  if (mergeResult.warnings.length > 0) {
537
541
  mergeResult.warnings.forEach((w) => console.log(` ${w}`));
538
542
  }
539
- return { recovered: true, itemsUpdated: mergeResult.itemsUpdated, newItemsPreserved: newItems.length };
543
+ return {
544
+ recovered: true,
545
+ itemsUpdated: mergeResult.itemsUpdated,
546
+ newItemsPreserved: newItems.length,
547
+ };
540
548
  }
541
549
  /**
542
550
  * Loads a valid copy of the PRD to keep in memory.
@@ -933,8 +941,6 @@ export async function run(args) {
933
941
  }
934
942
  let iterExitCode = 0;
935
943
  let iterOutput = "";
936
- let iterSterr = "";
937
- let iterSyncTotal = 0;
938
944
  // Get the base branch for branch state tracking
939
945
  let baseBranch = "main";
940
946
  const hasCommits = repoHasCommits();
@@ -1009,8 +1015,6 @@ export async function run(args) {
1009
1015
  clearBranchState();
1010
1016
  iterExitCode = result.exitCode;
1011
1017
  iterOutput = result.output;
1012
- iterSterr = result.stderr;
1013
- iterSyncTotal += result.syncResult.count;
1014
1018
  }
1015
1019
  }
1016
1020
  else if (targetBranch !== "" && !worktreesAvailable) {
@@ -1028,7 +1032,9 @@ export async function run(args) {
1028
1032
  console.warn(`\x1b[33mSkipping ${branchItems.length} branch item(s).\x1b[0m\n`);
1029
1033
  }
1030
1034
  // Process no-branch items in /workspace (when target is no-branch, or branch was skipped)
1031
- if (targetBranch === "" || (!worktreesAvailable && targetBranch !== "") || (!hasCommits && targetBranch !== "")) {
1035
+ if (targetBranch === "" ||
1036
+ (!worktreesAvailable && targetBranch !== "") ||
1037
+ (!hasCommits && targetBranch !== "")) {
1032
1038
  const noBranchItems = branchGroups.get("") || [];
1033
1039
  if (noBranchItems.length > 0) {
1034
1040
  const hasBranches = [...branchGroups.keys()].some((key) => key !== "");
@@ -1047,8 +1053,6 @@ export async function run(args) {
1047
1053
  filteredPrdPath = null;
1048
1054
  iterExitCode = result.exitCode;
1049
1055
  iterOutput = result.output;
1050
- iterSterr = result.stderr;
1051
- iterSyncTotal += result.syncResult.count;
1052
1056
  }
1053
1057
  }
1054
1058
  // Validate and recover PRD if the LLM corrupted it
@@ -376,7 +376,8 @@
376
376
  "checkCommand": "deno check **/*.ts",
377
377
  "testCommand": "deno test",
378
378
  "docker": {
379
- "install": "# Install Deno\nRUN curl -fsSL https://deno.land/install.sh | sh\nENV DENO_INSTALL=\"/root/.deno\"\nENV PATH=\"${DENO_INSTALL}/bin:${PATH}\""
379
+ "install": "# Install Deno (as node user so it installs to /home/node/.deno)\nRUN su - node -c 'curl -fsSL https://deno.land/install.sh | sh'\nENV DENO_INSTALL=\"/home/node/.deno\"\nENV PATH=\"${DENO_INSTALL}/bin:${PATH}\"",
380
+ "firewallDomains": ["deno.land", "jsr.io", "esm.sh"]
380
381
  },
381
382
  "technologies": [
382
383
  { "name": "Fresh", "description": "Next-gen web framework for Deno" },
@@ -47,6 +47,16 @@
47
47
  "trigger": "@code",
48
48
  "timeout": 300000,
49
49
  "maxLength": 2000
50
+ },
51
+ "audit": {
52
+ "name": "Security Auditor",
53
+ "description": "Claude Code responder for running npm audit and fixing vulnerabilities",
54
+ "type": "claude-code",
55
+ "trigger": "@audit",
56
+ "systemPrompt": "You are a security auditor for the {{project}} project. Your task is to:\n\n1. Run `npm audit` to identify security vulnerabilities\n2. Analyze the severity and impact of each vulnerability\n3. Run `npm audit fix` to apply safe, non-breaking fixes\n4. For remaining issues that require `--force`, evaluate whether the breaking changes are acceptable and apply them if safe\n5. For vulnerabilities that cannot be auto-fixed, investigate alternatives:\n - Check if a newer major version of the package resolves the issue\n - Look for alternative packages without vulnerabilities\n - Add overrides in package.json if the vulnerability is not exploitable in context\n6. Report a summary of what was fixed and what remains\n\nAlways run `npm audit` again after fixes to verify the final state. Prefer safe fixes over forced ones. Do not remove packages without confirming they are unused.\n\nIMPORTANT: After all fixes are applied, run `npm audit` one final time to verify the result. Include the full audit output in your response so the success pattern can be validated.",
57
+ "timeout": 300000,
58
+ "maxLength": 3000,
59
+ "successPattern": "found 0 vulnerabilities"
50
60
  }
51
61
  },
52
62
  "bundles": {
@@ -8,6 +8,14 @@
8
8
  "userInvocable": false
9
9
  }
10
10
  ],
11
+ "deno": [
12
+ {
13
+ "name": "deno-no-npm",
14
+ "description": "Prevents using npm/npx, yarn, or pnpm in Deno projects — use deno commands instead",
15
+ "instructions": "IMPORTANT: This project uses Deno as its runtime. Do NOT use npm, npx, yarn, or pnpm for package management or script execution.\n\nAVOID these commands:\n- `npm install`, `npm run`, `npm test`, `npm start`\n- `npx <package>`\n- `yarn add`, `yarn run`\n- `pnpm install`, `pnpm run`\n- Do NOT create or modify `package.json` for dependency management\n\nINSTEAD, use Deno's built-in tools:\n- Dependencies: Use `import` with URLs or `deno.json` imports map\n - `import { serve } from \"https://deno.land/std/http/server.ts\";`\n - Or add to `deno.json`: `{ \"imports\": { \"std/\": \"https://deno.land/std/\" } }`\n - Use `jsr:` imports for JSR packages: `import { z } from \"jsr:@zod/zod\";`\n- Run scripts: `deno run`, `deno task`\n- Type checking: `deno check`\n- Testing: `deno test`\n- Formatting: `deno fmt`\n- Linting: `deno lint`\n- Install tools: `deno install`\n- Compile: `deno compile`\n\nDeno uses `deno.json` (or `deno.jsonc`) for project configuration, NOT `package.json`.\n\nIf you need an npm package that has no Deno equivalent, use the `npm:` specifier:\n `import express from \"npm:express\";`\nThis uses Deno's built-in npm compatibility — no need to run `npm install`.",
16
+ "userInvocable": false
17
+ }
18
+ ],
11
19
  "swift": [
12
20
  {
13
21
  "name": "swift-main-naming",
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { fixPrd } from "./commands/fix-prd.js";
13
13
  import { fixConfig } from "./commands/fix-config.js";
14
14
  import { daemon } from "./commands/daemon.js";
15
15
  import { notify } from "./commands/notify.js";
16
+ import { ask } from "./commands/ask.js";
16
17
  import { chat } from "./commands/chat.js";
17
18
  import { listen } from "./commands/listen.js";
18
19
  import { config } from "./commands/config.js";
@@ -38,6 +39,7 @@ const commands = {
38
39
  docker,
39
40
  daemon,
40
41
  notify,
42
+ ask,
41
43
  chat,
42
44
  listen,
43
45
  config,
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "fs";
5
+ import { dirname, extname, join } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { z } from "zod";
8
+ import YAML from "yaml";
9
+ import { getRalphDir, getPrdFiles } from "./utils/config.js";
10
+ import { DEFAULT_PRD_YAML } from "./templates/prompts.js";
11
+ import { VALID_CATEGORIES } from "./utils/prd-validator.js";
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const CATEGORIES = VALID_CATEGORIES;
15
+ // Structured error codes for MCP tool error responses
16
+ const ErrorCode = {
17
+ PRD_NOT_FOUND: "PRD_NOT_FOUND",
18
+ PRD_PARSE_ERROR: "PRD_PARSE_ERROR",
19
+ PRD_WRITE_ERROR: "PRD_WRITE_ERROR",
20
+ INVALID_INDEX: "INVALID_INDEX",
21
+ INTERNAL_ERROR: "INTERNAL_ERROR",
22
+ };
23
+ function classifyError(err) {
24
+ const message = err instanceof Error ? err.message : String(err);
25
+ if (message.includes("No PRD file found") || message.includes("ENOENT")) {
26
+ return { code: ErrorCode.PRD_NOT_FOUND, message };
27
+ }
28
+ if (message.includes("does not contain an array") ||
29
+ message.includes("JSON") ||
30
+ message.includes("YAML")) {
31
+ return { code: ErrorCode.PRD_PARSE_ERROR, message };
32
+ }
33
+ if (message.includes("EACCES") || message.includes("EPERM")) {
34
+ return { code: ErrorCode.PRD_WRITE_ERROR, message };
35
+ }
36
+ return { code: ErrorCode.INTERNAL_ERROR, message };
37
+ }
38
+ function errorResponse(code, message) {
39
+ return {
40
+ content: [
41
+ {
42
+ type: "text",
43
+ text: JSON.stringify({ code, message }),
44
+ },
45
+ ],
46
+ isError: true,
47
+ };
48
+ }
49
+ const PRD_FILE_JSON = "prd.json";
50
+ /**
51
+ * Saves PRD entries to disk, auto-detecting format from file extension.
52
+ */
53
+ function savePrd(entries) {
54
+ const prdFiles = getPrdFiles();
55
+ const path = prdFiles.primary ?? join(getRalphDir(), PRD_FILE_JSON);
56
+ const ext = extname(path).toLowerCase();
57
+ try {
58
+ if (ext === ".yaml" || ext === ".yml") {
59
+ writeFileSync(path, YAML.stringify(entries));
60
+ }
61
+ else {
62
+ writeFileSync(path, JSON.stringify(entries, null, 2) + "\n");
63
+ }
64
+ // One-time migration: if a secondary PRD file exists, remove it now that
65
+ // the merged entries have been written to the primary file.
66
+ if (prdFiles.secondary && existsSync(prdFiles.secondary)) {
67
+ unlinkSync(prdFiles.secondary);
68
+ }
69
+ }
70
+ catch (err) {
71
+ const msg = err instanceof Error ? err.message : String(err);
72
+ throw new Error(`Failed to save PRD file at ${path}: ${msg}`);
73
+ }
74
+ }
75
+ function getVersion() {
76
+ try {
77
+ const packagePath = join(__dirname, "..", "package.json");
78
+ const packageJson = JSON.parse(readFileSync(packagePath, "utf-8"));
79
+ return packageJson.version ?? "unknown";
80
+ }
81
+ catch {
82
+ return "unknown";
83
+ }
84
+ }
85
+ /**
86
+ * Parses a PRD file based on its extension (MCP-safe version that throws instead of process.exit).
87
+ */
88
+ function parsePrdFile(path) {
89
+ const content = readFileSync(path, "utf-8");
90
+ const ext = extname(path).toLowerCase();
91
+ let result;
92
+ if (ext === ".yaml" || ext === ".yml") {
93
+ result = YAML.parse(content);
94
+ }
95
+ else {
96
+ result = JSON.parse(content);
97
+ }
98
+ if (result == null)
99
+ return [];
100
+ if (!Array.isArray(result)) {
101
+ throw new Error(`${path} does not contain an array`);
102
+ }
103
+ return result.map((entry, index) => {
104
+ if (typeof entry !== "object" || entry === null) {
105
+ throw new Error(`${path}[${index}]: entry must be an object`);
106
+ }
107
+ const obj = entry;
108
+ if (typeof obj.category !== "string" || !CATEGORIES.includes(obj.category)) {
109
+ throw new Error(`${path}[${index}]: missing or invalid "category" (expected one of: ${CATEGORIES.join(", ")})`);
110
+ }
111
+ if (typeof obj.description !== "string" || obj.description.trim().length === 0) {
112
+ throw new Error(`${path}[${index}]: missing or invalid "description" (expected string)`);
113
+ }
114
+ if (!Array.isArray(obj.steps) || !obj.steps.every((s) => typeof s === "string")) {
115
+ throw new Error(`${path}[${index}]: missing or invalid "steps" (expected string array)`);
116
+ }
117
+ if (typeof obj.passes !== "boolean") {
118
+ throw new Error(`${path}[${index}]: missing or invalid "passes" (expected boolean)`);
119
+ }
120
+ if (obj.branch !== undefined && typeof obj.branch !== "string") {
121
+ throw new Error(`${path}[${index}]: invalid "branch" (expected string)`);
122
+ }
123
+ return {
124
+ category: obj.category,
125
+ description: obj.description,
126
+ steps: obj.steps,
127
+ passes: obj.passes,
128
+ ...(obj.branch !== undefined && { branch: obj.branch }),
129
+ };
130
+ });
131
+ }
132
+ /**
133
+ * Loads PRD entries (MCP-safe version that throws instead of process.exit).
134
+ */
135
+ function loadPrd() {
136
+ const prdFiles = getPrdFiles();
137
+ if (prdFiles.none) {
138
+ const ralphDir = getRalphDir();
139
+ const prdPath = join(ralphDir, "prd.yaml");
140
+ try {
141
+ if (!existsSync(ralphDir)) {
142
+ mkdirSync(ralphDir, { recursive: true });
143
+ }
144
+ writeFileSync(prdPath, DEFAULT_PRD_YAML);
145
+ }
146
+ catch (err) {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ throw new Error(`Failed to save PRD file at ${prdPath}: ${msg}`);
149
+ }
150
+ return parsePrdFile(prdPath);
151
+ }
152
+ if (!prdFiles.primary) {
153
+ throw new Error("No PRD file found. Run `ralph init` to create one.");
154
+ }
155
+ const primary = parsePrdFile(prdFiles.primary);
156
+ if (prdFiles.both && prdFiles.secondary) {
157
+ const secondary = parsePrdFile(prdFiles.secondary);
158
+ return [...primary, ...secondary];
159
+ }
160
+ return primary;
161
+ }
162
+ const server = new McpServer({
163
+ name: "ralph-mcp",
164
+ version: getVersion(),
165
+ });
166
+ // ralph_prd_list tool
167
+ server.tool("ralph_prd_list", "List PRD entries with optional category and status filters", {
168
+ category: z.enum(CATEGORIES).optional().describe("Filter by category"),
169
+ status: z
170
+ .enum(["all", "passing", "failing"])
171
+ .optional()
172
+ .describe("Filter by status: all (default), passing, or failing"),
173
+ }, async ({ category, status }) => {
174
+ try {
175
+ const prd = loadPrd();
176
+ let filtered = prd.map((entry, i) => ({ ...entry, index: i + 1 }));
177
+ if (category) {
178
+ filtered = filtered.filter((entry) => entry.category === category);
179
+ }
180
+ if (status === "passing") {
181
+ filtered = filtered.filter((entry) => entry.passes);
182
+ }
183
+ else if (status === "failing") {
184
+ filtered = filtered.filter((entry) => !entry.passes);
185
+ }
186
+ return {
187
+ content: [
188
+ {
189
+ type: "text",
190
+ text: JSON.stringify(filtered, null, 2),
191
+ },
192
+ ],
193
+ };
194
+ }
195
+ catch (err) {
196
+ const { code, message } = classifyError(err);
197
+ return errorResponse(code, message);
198
+ }
199
+ });
200
+ // ralph_prd_add tool
201
+ server.tool("ralph_prd_add", "Add a new PRD entry with category, description, and verification steps", {
202
+ category: z.enum(CATEGORIES).describe("Category for the new entry"),
203
+ description: z.string().min(1).describe("Description of the requirement"),
204
+ steps: z
205
+ .array(z.string().min(1))
206
+ .min(1)
207
+ .describe("Verification steps to check if requirement is met"),
208
+ branch: z.string().optional().describe("Git branch associated with this entry"),
209
+ }, async ({ category, description, steps, branch }) => {
210
+ try {
211
+ const entry = {
212
+ category,
213
+ description,
214
+ steps,
215
+ passes: false,
216
+ };
217
+ if (branch) {
218
+ entry.branch = branch;
219
+ }
220
+ const prd = loadPrd();
221
+ prd.push(entry);
222
+ savePrd(prd);
223
+ return {
224
+ content: [
225
+ {
226
+ type: "text",
227
+ text: JSON.stringify({
228
+ message: `Added entry #${prd.length}: "${description}"`,
229
+ entry: { ...entry, index: prd.length },
230
+ }, null, 2),
231
+ },
232
+ ],
233
+ };
234
+ }
235
+ catch (err) {
236
+ const { code, message } = classifyError(err);
237
+ return errorResponse(code, message);
238
+ }
239
+ });
240
+ // ralph_prd_status tool
241
+ server.tool("ralph_prd_status", "Get PRD completion status with counts, percentage, per-category breakdown, and remaining items", {}, async () => {
242
+ try {
243
+ const prd = loadPrd();
244
+ if (prd.length === 0) {
245
+ return {
246
+ content: [
247
+ {
248
+ type: "text",
249
+ text: JSON.stringify({ passing: 0, total: 0, percentage: 0, categories: {}, remaining: [] }, null, 2),
250
+ },
251
+ ],
252
+ };
253
+ }
254
+ const passing = prd.filter((e) => e.passes).length;
255
+ const total = prd.length;
256
+ const percentage = Math.round((passing / total) * 100);
257
+ const categories = {};
258
+ prd.forEach((entry) => {
259
+ if (!categories[entry.category]) {
260
+ categories[entry.category] = { passing: 0, total: 0 };
261
+ }
262
+ categories[entry.category].total++;
263
+ if (entry.passes)
264
+ categories[entry.category].passing++;
265
+ });
266
+ const remaining = prd.reduce((acc, entry, i) => {
267
+ if (!entry.passes) {
268
+ acc.push({ index: i + 1, category: entry.category, description: entry.description });
269
+ }
270
+ return acc;
271
+ }, []);
272
+ return {
273
+ content: [
274
+ {
275
+ type: "text",
276
+ text: JSON.stringify({ passing, total, percentage, categories, remaining }, null, 2),
277
+ },
278
+ ],
279
+ };
280
+ }
281
+ catch (err) {
282
+ const { code, message } = classifyError(err);
283
+ return errorResponse(code, message);
284
+ }
285
+ });
286
+ // ralph_prd_toggle tool
287
+ server.tool("ralph_prd_toggle", "Toggle completion status (passes) for PRD entries by 1-based index", {
288
+ indices: z
289
+ .array(z.number().int().min(1))
290
+ .min(1)
291
+ .describe("1-based indices of PRD entries to toggle"),
292
+ }, async ({ indices }) => {
293
+ try {
294
+ const prd = loadPrd();
295
+ // Validate all indices are in range
296
+ for (const index of indices) {
297
+ if (index > prd.length) {
298
+ return errorResponse(ErrorCode.INVALID_INDEX, `Invalid entry number: ${index}. Must be 1-${prd.length}`);
299
+ }
300
+ }
301
+ // Deduplicate and sort
302
+ const uniqueIndices = [...new Set(indices)].sort((a, b) => a - b);
303
+ const toggled = uniqueIndices.map((index) => {
304
+ const entry = prd[index - 1];
305
+ entry.passes = !entry.passes;
306
+ return {
307
+ index,
308
+ description: entry.description,
309
+ passes: entry.passes,
310
+ };
311
+ });
312
+ savePrd(prd);
313
+ return {
314
+ content: [
315
+ {
316
+ type: "text",
317
+ text: JSON.stringify({ message: `Toggled ${toggled.length} entry/entries`, toggled }, null, 2),
318
+ },
319
+ ],
320
+ };
321
+ }
322
+ catch (err) {
323
+ const { code, message } = classifyError(err);
324
+ return errorResponse(code, message);
325
+ }
326
+ });
327
+ function isConnectionError(error) {
328
+ const message = error instanceof Error ? error.message : String(error);
329
+ return (message.includes("EPIPE") ||
330
+ message.includes("ECONNRESET") ||
331
+ message.includes("ECONNREFUSED") ||
332
+ message.includes("ERR_USE_AFTER_CLOSE") ||
333
+ message.includes("write after end") ||
334
+ message.includes("This socket has been ended") ||
335
+ message.includes("transport") ||
336
+ message.includes("broken pipe"));
337
+ }
338
+ async function main() {
339
+ try {
340
+ const transport = new StdioServerTransport();
341
+ await server.connect(transport);
342
+ }
343
+ catch (error) {
344
+ if (isConnectionError(error)) {
345
+ console.error("MCP connection error: transport closed or unavailable.");
346
+ process.exit(0);
347
+ }
348
+ console.error("Server error:", error);
349
+ process.exit(1);
350
+ }
351
+ }
352
+ process.on("uncaughtException", (error) => {
353
+ if (isConnectionError(error)) {
354
+ process.exit(0);
355
+ }
356
+ console.error("Uncaught exception:", error);
357
+ process.exit(1);
358
+ });
359
+ process.on("unhandledRejection", (reason, promise) => {
360
+ if (isConnectionError(reason)) {
361
+ process.exit(0);
362
+ }
363
+ console.error("Unhandled rejection at:", promise, "reason:", reason);
364
+ process.exit(1);
365
+ });
366
+ main();
@@ -175,7 +175,7 @@ export class TelegramChatClient {
175
175
  reject(new Error(`Telegram API error: ${response.description || "Unknown error"} (code: ${response.error_code})`));
176
176
  }
177
177
  }
178
- catch (err) {
178
+ catch {
179
179
  reject(new Error(`Failed to parse Telegram response: ${data}`));
180
180
  }
181
181
  });
@@ -40,8 +40,15 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
40
40
  let killed = false;
41
41
  let lastProgressSent = 0;
42
42
  let progressTimer = null;
43
+ // Build effective prompt: prepend systemPrompt if configured
44
+ let effectivePrompt = prompt;
45
+ if (responderConfig.systemPrompt) {
46
+ effectivePrompt = prompt
47
+ ? `${responderConfig.systemPrompt}\n\nUser request: ${prompt}`
48
+ : responderConfig.systemPrompt;
49
+ }
43
50
  // Build the command arguments
44
- const args = ["-p", prompt, "--dangerously-skip-permissions", "--print"];
51
+ const args = ["-p", effectivePrompt, "--dangerously-skip-permissions", "--print"];
45
52
  // Spawn claude process
46
53
  let proc;
47
54
  try {
@@ -115,6 +122,21 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
115
122
  if (code === 0 || code === null) {
116
123
  // Success - format and truncate output
117
124
  const output = formatClaudeCodeOutput(stdout);
125
+ // Check successPattern if configured - output must match for run to succeed
126
+ if (responderConfig.successPattern) {
127
+ const pattern = new RegExp(responderConfig.successPattern, "i");
128
+ if (!pattern.test(output)) {
129
+ const { text, truncated, originalLength } = truncateResponse(output, maxLength);
130
+ resolve({
131
+ success: false,
132
+ response: text,
133
+ error: `Output did not match required success pattern: ${responderConfig.successPattern}`,
134
+ truncated,
135
+ originalLength: truncated ? originalLength : undefined,
136
+ });
137
+ return;
138
+ }
139
+ }
118
140
  const { text, truncated, originalLength } = truncateResponse(output, maxLength);
119
141
  resolve({
120
142
  success: true,
@@ -155,6 +177,7 @@ export async function executeClaudeCodeResponder(prompt, responderConfig, option
155
177
  */
156
178
  function formatClaudeCodeOutput(output) {
157
179
  // Remove ANSI escape codes
180
+ // oxlint-disable-next-line no-control-regex
158
181
  let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
159
182
  // Remove carriage returns (used for progress overwriting)
160
183
  cleaned = cleaned.replace(/\r/g, "");
@@ -244,6 +244,7 @@ function formatCLIOutput(stdout, stderr) {
244
244
  output = output.trim() + "\n\n[stderr]\n" + stderr.trim();
245
245
  }
246
246
  // Remove ANSI escape codes
247
+ // oxlint-disable-next-line no-control-regex
247
248
  let cleaned = output.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "");
248
249
  // Remove carriage returns (used for progress overwriting)
249
250
  cleaned = cleaned.replace(/\r/g, "");
@@ -2,7 +2,7 @@
2
2
  * LLM Responder - Sends messages to LLM providers and returns responses.
3
3
  * Used by chat clients to respond to messages matched by the responder matcher.
4
4
  */
5
- import { getLLMProviders, loadConfig, } from "../utils/config.js";
5
+ import { getLLMProviders, loadConfig } from "../utils/config.js";
6
6
  import { createLLMClient } from "../utils/llm-client.js";
7
7
  import { createResponderLog } from "../utils/responder-logger.js";
8
8
  import { basename, resolve } from "path";