ralph-hero-mcp-server 2.5.42 → 2.5.51

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/dist/index.js CHANGED
@@ -23,6 +23,7 @@ import { registerHygieneTools } from "./tools/hygiene-tools.js";
23
23
  import { registerDebugTools } from "./tools/debug-tools.js";
24
24
  import { registerDecomposeTools } from "./tools/decompose-tools.js";
25
25
  import { registerViewTools } from "./tools/view-tools.js";
26
+ import { registerPlanGraphTools } from "./tools/plan-graph-tools.js";
26
27
  /**
27
28
  * Initialize the GitHub client from environment variables.
28
29
  */
@@ -304,6 +305,8 @@ async function main() {
304
305
  registerDecomposeTools(server, client, fieldCache);
305
306
  // View management tools (REST API view creation)
306
307
  registerViewTools(server, client, fieldCache);
308
+ // Plan graph sync tool (sync plan dependency edges to GitHub)
309
+ registerPlanGraphTools(server, client);
307
310
  // Debug tools (only when RALPH_DEBUG=true)
308
311
  if (process.env.RALPH_DEBUG === 'true') {
309
312
  registerDebugTools(server, client);
@@ -319,7 +319,8 @@ export function resolveConfig(client, args) {
319
319
  const owner = args.owner || client.config.owner;
320
320
  const repo = args.repo || client.config.repo;
321
321
  if (!owner)
322
- throw new Error("owner is required (set RALPH_GH_OWNER env var or pass explicitly)");
322
+ throw new Error("owner is required. Set RALPH_GH_OWNER in ~/.claude/settings.json (user-scoped) " +
323
+ "or .claude/settings.local.json (project-scoped), or pass owner explicitly.");
323
324
  if (!repo)
324
325
  throw new Error("repo is required. Set RALPH_GH_REPO env var, pass repo explicitly, or link exactly one repo to your project.");
325
326
  return { owner, repo };
@@ -334,7 +335,8 @@ export function resolveConfig(client, args) {
334
335
  export function resolveConfigOptionalRepo(client, args) {
335
336
  const owner = args.owner || client.config.owner;
336
337
  if (!owner)
337
- throw new Error("owner is required (set RALPH_GH_OWNER env var or pass explicitly)");
338
+ throw new Error("owner is required. Set RALPH_GH_OWNER in ~/.claude/settings.json (user-scoped) " +
339
+ "or .claude/settings.local.json (project-scoped), or pass owner explicitly.");
338
340
  const repo = args.repo || client.config.repo;
339
341
  return { owner, repo };
340
342
  }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Plan dependency graph parser.
3
+ *
4
+ * Extracts issue-level dependency edges from plan markdown documents.
5
+ * Pure function — no I/O, no GitHub calls.
6
+ */
7
+ /**
8
+ * Parse frontmatter from `---` fenced block at the start of content.
9
+ * Returns key-value pairs as raw strings.
10
+ */
11
+ function parseFrontmatter(content) {
12
+ const match = content.match(/^---\n([\s\S]*?)\n---/);
13
+ if (!match)
14
+ return {};
15
+ const result = {};
16
+ for (const line of match[1].split("\n")) {
17
+ const colonIdx = line.indexOf(":");
18
+ if (colonIdx === -1)
19
+ continue;
20
+ const key = line.slice(0, colonIdx).trim();
21
+ const value = line.slice(colonIdx + 1).trim();
22
+ result[key] = value;
23
+ }
24
+ return result;
25
+ }
26
+ /**
27
+ * Parse a YAML-style inline array of numbers, e.g. `[660, 661, 662]`.
28
+ */
29
+ function parseNumberArray(raw) {
30
+ const inner = raw.replace(/^\[/, "").replace(/\]$/, "");
31
+ if (!inner.trim())
32
+ return [];
33
+ return inner.split(",").map((s) => Number(s.trim()));
34
+ }
35
+ /**
36
+ * Parse a depends_on value like `[phase-1, phase-2]` or `[GH-44]` or `null`.
37
+ * Returns raw string references (e.g. "phase-1", "GH-44") or empty array for null/absent.
38
+ */
39
+ function parseDependsOn(raw) {
40
+ const trimmed = raw.trim();
41
+ if (trimmed === "null" || trimmed === "")
42
+ return [];
43
+ const inner = trimmed.replace(/^\[/, "").replace(/\]$/, "");
44
+ if (!inner.trim())
45
+ return [];
46
+ return inner.split(",").map((s) => s.trim());
47
+ }
48
+ /**
49
+ * Parse a plan markdown document and extract the dependency graph.
50
+ */
51
+ export function parsePlanGraph(content) {
52
+ const frontmatter = parseFrontmatter(content);
53
+ const type = (frontmatter.type ?? "plan");
54
+ const issues = parseNumberArray(frontmatter.github_issues ?? "[]");
55
+ const primaryIssue = Number(frontmatter.primary_issue ?? "0");
56
+ const phaseToIssue = new Map();
57
+ const edges = [];
58
+ const lines = content.split("\n");
59
+ if (type === "plan") {
60
+ // Scan for ## Phase N: ... (GH-NNN) headings
61
+ const phasePattern = /^## Phase (\d+):.*\(GH-(\d+)\)/;
62
+ // First pass: build phaseToIssue map
63
+ for (const line of lines) {
64
+ const m = line.match(phasePattern);
65
+ if (m) {
66
+ phaseToIssue.set(Number(m[1]), Number(m[2]));
67
+ }
68
+ }
69
+ // Second pass: find depends_on after each phase heading
70
+ let currentPhaseIssue = null;
71
+ for (const line of lines) {
72
+ const phaseMatch = line.match(phasePattern);
73
+ if (phaseMatch) {
74
+ currentPhaseIssue = Number(phaseMatch[2]);
75
+ continue;
76
+ }
77
+ // Check for heading that would end the current phase section
78
+ if (/^##\s/.test(line) && !phaseMatch) {
79
+ currentPhaseIssue = null;
80
+ continue;
81
+ }
82
+ if (currentPhaseIssue !== null) {
83
+ const depMatch = line.match(/^\s*-\s+\*\*depends_on\*\*:\s*(.*)/);
84
+ if (depMatch) {
85
+ const refs = parseDependsOn(depMatch[1]);
86
+ for (const ref of refs) {
87
+ const phaseRef = ref.match(/^phase-(\d+)$/);
88
+ const ghRef = ref.match(/^GH-(\d+)$/);
89
+ if (phaseRef) {
90
+ const blockingIssue = phaseToIssue.get(Number(phaseRef[1]));
91
+ if (blockingIssue !== undefined) {
92
+ edges.push({
93
+ blocked: currentPhaseIssue,
94
+ blocking: blockingIssue,
95
+ source: "phase-level",
96
+ });
97
+ }
98
+ }
99
+ else if (ghRef) {
100
+ edges.push({
101
+ blocked: currentPhaseIssue,
102
+ blocking: Number(ghRef[1]),
103
+ source: "phase-level",
104
+ });
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ }
111
+ else {
112
+ // plan-of-plans: scan for ### Feature ...: ... (GH-NNN) headings
113
+ const featurePattern = /^### Feature [^:]+:.*\(GH-(\d+)\)/;
114
+ let currentFeatureIssue = null;
115
+ for (const line of lines) {
116
+ const featureMatch = line.match(featurePattern);
117
+ if (featureMatch) {
118
+ currentFeatureIssue = Number(featureMatch[1]);
119
+ continue;
120
+ }
121
+ // Check for any heading (h2 or h3) that would end the current feature section
122
+ if (/^##\s/.test(line) && !featureMatch) {
123
+ currentFeatureIssue = null;
124
+ continue;
125
+ }
126
+ if (currentFeatureIssue !== null) {
127
+ const depMatch = line.match(/^\s*-\s+\*\*depends_on\*\*:\s*(.*)/);
128
+ if (depMatch) {
129
+ const refs = parseDependsOn(depMatch[1]);
130
+ for (const ref of refs) {
131
+ const ghRef = ref.match(/^GH-(\d+)$/);
132
+ if (ghRef) {
133
+ edges.push({
134
+ blocked: currentFeatureIssue,
135
+ blocking: Number(ghRef[1]),
136
+ source: "feature-level",
137
+ });
138
+ }
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+ return {
145
+ type,
146
+ issues,
147
+ primaryIssue,
148
+ phaseToIssue,
149
+ edges,
150
+ };
151
+ }
152
+ //# sourceMappingURL=plan-graph.js.map
@@ -41,6 +41,7 @@ const COMMAND_ALLOWED_STATES = {
41
41
  ralph_research: ["Research in Progress", "Ready for Plan", "Human Needed"],
42
42
  ralph_plan: ["Plan in Progress", "Plan in Review", "In Progress", "Human Needed"],
43
43
  ralph_plan_epic: ["Plan in Progress", "In Progress", "Human Needed"],
44
+ ralph_pr: ["In Review", "Human Needed"],
44
45
  ralph_impl: ["In Progress", "In Review", "Human Needed"],
45
46
  ralph_review: ["In Progress", "Ready for Plan", "Human Needed"],
46
47
  ralph_hero: ["In Review", "Human Needed"],
@@ -0,0 +1,204 @@
1
+ /**
2
+ * MCP tool for syncing plan dependency graphs to GitHub.
3
+ *
4
+ * Reads a plan markdown document, extracts dependency edges via parsePlanGraph,
5
+ * diffs against existing GitHub blockedBy edges, and adds/removes edges to
6
+ * converge the graph.
7
+ */
8
+ import { z } from "zod";
9
+ import { readFile } from "fs/promises";
10
+ import { parsePlanGraph } from "../lib/plan-graph.js";
11
+ import { toolSuccess, toolError } from "../types.js";
12
+ import { resolveIssueNodeId, resolveConfig } from "../lib/helpers.js";
13
+ // ---------------------------------------------------------------------------
14
+ // Pure function: diffDependencyEdges
15
+ // ---------------------------------------------------------------------------
16
+ /**
17
+ * Compute the diff between declared edges (from the plan) and existing edges
18
+ * (from GitHub), scoped to plan issues only.
19
+ *
20
+ * - added: edges in declared but not in existing
21
+ * - removed: edges in existing but not in declared, where BOTH endpoints are plan issues
22
+ * - unchanged: edges present in both declared and existing
23
+ *
24
+ * External edges (where one endpoint is outside the plan) are left alone.
25
+ *
26
+ * @param declared - Edges parsed from the plan document
27
+ * @param existing - Edges currently on GitHub (blockedBy relationships)
28
+ * @param planIssues - The set of issue numbers that belong to this plan
29
+ */
30
+ export function diffDependencyEdges(declared, existing, planIssues) {
31
+ const edgeKey = (e) => `${e.blocked}:${e.blocking}`;
32
+ const declaredSet = new Set(declared.map(edgeKey));
33
+ const existingSet = new Set(existing.map(edgeKey));
34
+ const added = declared.filter((e) => !existingSet.has(edgeKey(e)));
35
+ const unchanged = declared.filter((e) => existingSet.has(edgeKey(e)));
36
+ const removed = existing.filter((e) => !declaredSet.has(edgeKey(e)) &&
37
+ planIssues.has(e.blocked) &&
38
+ planIssues.has(e.blocking));
39
+ return { added, removed, unchanged };
40
+ }
41
+ // ---------------------------------------------------------------------------
42
+ // Register plan graph tools
43
+ // ---------------------------------------------------------------------------
44
+ export function registerPlanGraphTools(server, client) {
45
+ // -------------------------------------------------------------------------
46
+ // ralph_hero__sync_plan_graph
47
+ // -------------------------------------------------------------------------
48
+ server.tool("ralph_hero__sync_plan_graph", "Sync a plan document's dependency graph to GitHub blockedBy edges. " +
49
+ "Reads the plan file, extracts dependency edges, diffs against existing GitHub edges " +
50
+ "scoped to plan issues, and adds missing / removes stale edges. " +
51
+ "Use dryRun=true to preview changes without mutating (default: false).", {
52
+ planPath: z
53
+ .string()
54
+ .describe("Absolute path to the plan markdown document"),
55
+ dryRun: z
56
+ .boolean()
57
+ .optional()
58
+ .default(false)
59
+ .describe("When true, report what would change without mutating GitHub. Default: false."),
60
+ }, async (args) => {
61
+ try {
62
+ // Step 1: Read the plan file
63
+ let content;
64
+ try {
65
+ content = await readFile(args.planPath, "utf-8");
66
+ }
67
+ catch (err) {
68
+ const message = err instanceof Error ? err.message : String(err);
69
+ return toolError(`Failed to read plan file: ${message}`);
70
+ }
71
+ // Step 2: Parse the dependency graph
72
+ const graph = parsePlanGraph(content);
73
+ if (graph.issues.length === 0) {
74
+ return toolError("Plan has no github_issues in frontmatter. Cannot sync dependencies.");
75
+ }
76
+ const planIssues = new Set(graph.issues);
77
+ const { owner, repo } = resolveConfig(client, {});
78
+ // Step 3: Query existing blockedBy edges for all plan issues
79
+ const existingEdges = [];
80
+ for (const issueNum of graph.issues) {
81
+ try {
82
+ const result = await client.query(`query($owner: String!, $repo: String!, $number: Int!) {
83
+ repository(owner: $owner, name: $repo) {
84
+ issue(number: $number) {
85
+ blockedBy(first: 50) {
86
+ nodes { number }
87
+ }
88
+ }
89
+ }
90
+ }`, { owner, repo, number: issueNum });
91
+ const blockers = result.repository?.issue?.blockedBy?.nodes ?? [];
92
+ for (const blocker of blockers) {
93
+ existingEdges.push({
94
+ blocked: issueNum,
95
+ blocking: blocker.number,
96
+ source: "phase-level", // source doesn't matter for existing edges
97
+ });
98
+ }
99
+ }
100
+ catch (err) {
101
+ // If we can't query an issue, skip it — it may not exist yet
102
+ const message = err instanceof Error ? err.message : String(err);
103
+ console.error(`[ralph-hero] Warning: could not query blockedBy for #${issueNum}: ${message}`);
104
+ }
105
+ }
106
+ // Step 4: Diff
107
+ const diff = diffDependencyEdges(graph.edges, existingEdges, planIssues);
108
+ // Step 5: If dryRun, return diff without mutating
109
+ if (args.dryRun) {
110
+ return toolSuccess({
111
+ dryRun: true,
112
+ planPath: args.planPath,
113
+ planType: graph.type,
114
+ planIssues: graph.issues,
115
+ added: diff.added.map((e) => ({
116
+ blocked: e.blocked,
117
+ blocking: e.blocking,
118
+ })),
119
+ removed: diff.removed.map((e) => ({
120
+ blocked: e.blocked,
121
+ blocking: e.blocking,
122
+ })),
123
+ unchanged: diff.unchanged.map((e) => ({
124
+ blocked: e.blocked,
125
+ blocking: e.blocking,
126
+ })),
127
+ errors: [],
128
+ });
129
+ }
130
+ // Step 6: Apply mutations
131
+ const errors = [];
132
+ // Add missing edges
133
+ for (const edge of diff.added) {
134
+ try {
135
+ const blockedId = await resolveIssueNodeId(client, owner, repo, edge.blocked);
136
+ const blockingId = await resolveIssueNodeId(client, owner, repo, edge.blocking);
137
+ await client.mutate(`mutation($blockedId: ID!, $blockingId: ID!) {
138
+ addBlockedBy(input: {
139
+ issueId: $blockedId,
140
+ blockingIssueId: $blockingId
141
+ }) {
142
+ issue { id number }
143
+ blockingIssue { id number }
144
+ }
145
+ }`, { blockedId, blockingId });
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ errors.push({
150
+ edge: `#${edge.blocked} blocked by #${edge.blocking}`,
151
+ error: `addBlockedBy failed: ${message}`,
152
+ });
153
+ }
154
+ }
155
+ // Remove stale edges
156
+ for (const edge of diff.removed) {
157
+ try {
158
+ const blockedId = await resolveIssueNodeId(client, owner, repo, edge.blocked);
159
+ const blockingId = await resolveIssueNodeId(client, owner, repo, edge.blocking);
160
+ await client.mutate(`mutation($blockedId: ID!, $blockingId: ID!) {
161
+ removeBlockedBy(input: {
162
+ issueId: $blockedId,
163
+ blockingIssueId: $blockingId
164
+ }) {
165
+ issue { id number }
166
+ blockingIssue { id number }
167
+ }
168
+ }`, { blockedId, blockingId });
169
+ }
170
+ catch (err) {
171
+ const message = err instanceof Error ? err.message : String(err);
172
+ errors.push({
173
+ edge: `#${edge.blocked} blocked by #${edge.blocking}`,
174
+ error: `removeBlockedBy failed: ${message}`,
175
+ });
176
+ }
177
+ }
178
+ return toolSuccess({
179
+ dryRun: false,
180
+ planPath: args.planPath,
181
+ planType: graph.type,
182
+ planIssues: graph.issues,
183
+ added: diff.added.map((e) => ({
184
+ blocked: e.blocked,
185
+ blocking: e.blocking,
186
+ })),
187
+ removed: diff.removed.map((e) => ({
188
+ blocked: e.blocked,
189
+ blocking: e.blocking,
190
+ })),
191
+ unchanged: diff.unchanged.map((e) => ({
192
+ blocked: e.blocked,
193
+ blocking: e.blocking,
194
+ })),
195
+ errors,
196
+ });
197
+ }
198
+ catch (error) {
199
+ const message = error instanceof Error ? error.message : String(error);
200
+ return toolError(`Failed to sync plan graph: ${message}`);
201
+ }
202
+ });
203
+ }
204
+ //# sourceMappingURL=plan-graph-tools.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.42",
3
+ "version": "2.5.51",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",