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 +3 -0
- package/dist/lib/helpers.js +4 -2
- package/dist/lib/plan-graph.js +152 -0
- package/dist/lib/state-resolution.js +1 -0
- package/dist/tools/plan-graph-tools.js +204 -0
- package/package.json +1 -1
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);
|
package/dist/lib/helpers.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|