ralph-hero-mcp-server 1.1.2 → 1.2.0

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.
@@ -30,7 +30,8 @@ export function createGitHubClient(clientConfig) {
30
30
  },
31
31
  });
32
32
  // Create a separate graphql instance for project operations if a different token is configured
33
- const hasProjectToken = clientConfig.projectToken && clientConfig.projectToken !== clientConfig.token;
33
+ const hasProjectToken = clientConfig.projectToken &&
34
+ clientConfig.projectToken !== clientConfig.token;
34
35
  const projectGraphqlWithAuth = hasProjectToken
35
36
  ? graphql.defaults({
36
37
  headers: {
@@ -55,7 +56,8 @@ export function createGitHubClient(clientConfig) {
55
56
  const insertPos = fullQuery.indexOf("{", fullQuery.indexOf(match[0])) + 1;
56
57
  fullQuery =
57
58
  fullQuery.slice(0, insertPos) +
58
- "\n " + RATE_LIMIT_FRAGMENT +
59
+ "\n " +
60
+ RATE_LIMIT_FRAGMENT +
59
61
  fullQuery.slice(insertPos);
60
62
  }
61
63
  }
@@ -77,7 +79,7 @@ export function createGitHubClient(clientConfig) {
77
79
  "status" in error &&
78
80
  error.status === 403) {
79
81
  const retryAfter = error && typeof error === "object" && "headers" in error
80
- ? (error.headers?.["retry-after"])
82
+ ? error.headers?.["retry-after"]
81
83
  : undefined;
82
84
  if (retryAfter) {
83
85
  const waitMs = parseInt(retryAfter, 10) * 1000;
package/dist/index.js CHANGED
@@ -27,8 +27,7 @@ function resolveEnv(name) {
27
27
  }
28
28
  function initGitHubClient() {
29
29
  // Repo token: for repository operations (issues, PRs, comments)
30
- const repoToken = resolveEnv("RALPH_GH_REPO_TOKEN") ||
31
- resolveEnv("RALPH_HERO_GITHUB_TOKEN");
30
+ const repoToken = resolveEnv("RALPH_GH_REPO_TOKEN") || resolveEnv("RALPH_HERO_GITHUB_TOKEN");
32
31
  // Project token: for Projects V2 operations (fields, workflow state)
33
32
  // Falls back to repo token if not set
34
33
  const projectToken = resolveEnv("RALPH_GH_PROJECT_TOKEN") || repoToken;
@@ -63,7 +62,9 @@ function initGitHubClient() {
63
62
  "Project-level tools (workflow state, field updates) will not work.\n" +
64
63
  "Run /ralph-setup to configure your GitHub Project.");
65
64
  }
66
- const repoTokenSource = resolveEnv("RALPH_GH_REPO_TOKEN") ? "RALPH_GH_REPO_TOKEN" : "RALPH_HERO_GITHUB_TOKEN";
65
+ const repoTokenSource = resolveEnv("RALPH_GH_REPO_TOKEN")
66
+ ? "RALPH_GH_REPO_TOKEN"
67
+ : "RALPH_HERO_GITHUB_TOKEN";
67
68
  console.error(`[ralph-hero] Repo token: ${repoTokenSource}`);
68
69
  if (projectToken !== repoToken) {
69
70
  console.error(`[ralph-hero] Project token: RALPH_GH_PROJECT_TOKEN (separate)`);
@@ -91,7 +92,10 @@ function registerCoreTools(server, client) {
91
92
  checks.auth = { status: "ok", detail: `Authenticated as ${login}` };
92
93
  }
93
94
  catch (e) {
94
- checks.auth = { status: "fail", detail: `Auth failed: ${e instanceof Error ? e.message : String(e)}` };
95
+ checks.auth = {
96
+ status: "fail",
97
+ detail: `Auth failed: ${e instanceof Error ? e.message : String(e)}`,
98
+ };
95
99
  }
96
100
  // 2. Repo access check
97
101
  if (client.config.owner && client.config.repo) {
@@ -99,14 +103,23 @@ function registerCoreTools(server, client) {
99
103
  await client.query(`query($owner: String!, $repo: String!) {
100
104
  repository(owner: $owner, name: $repo) { nameWithOwner }
101
105
  }`, { owner: client.config.owner, repo: client.config.repo });
102
- checks.repoAccess = { status: "ok", detail: `${client.config.owner}/${client.config.repo}` };
106
+ checks.repoAccess = {
107
+ status: "ok",
108
+ detail: `${client.config.owner}/${client.config.repo}`,
109
+ };
103
110
  }
104
111
  catch (e) {
105
- checks.repoAccess = { status: "fail", detail: `Cannot access repo: ${e instanceof Error ? e.message : String(e)}. Token may lack 'repo' scope or org access.` };
112
+ checks.repoAccess = {
113
+ status: "fail",
114
+ detail: `Cannot access repo: ${e instanceof Error ? e.message : String(e)}. Token may lack 'repo' scope or org access.`,
115
+ };
106
116
  }
107
117
  }
108
118
  else {
109
- checks.repoAccess = { status: "skip", detail: "RALPH_GH_OWNER/RALPH_GH_REPO not set" };
119
+ checks.repoAccess = {
120
+ status: "skip",
121
+ detail: "RALPH_GH_OWNER/RALPH_GH_REPO not set",
122
+ };
110
123
  }
111
124
  // 3. Project access check (uses project token + project owner)
112
125
  const projOwner = resolveProjectOwner(client.config);
@@ -139,28 +152,46 @@ function registerCoreTools(server, client) {
139
152
  }
140
153
  }
141
154
  if (project) {
142
- checks.projectAccess = { status: "ok", detail: `${project.title} (#${projNum})` };
155
+ checks.projectAccess = {
156
+ status: "ok",
157
+ detail: `${project.title} (#${projNum})`,
158
+ };
143
159
  // 4. Required fields check
144
160
  const requiredFields = ["Workflow State", "Priority", "Estimate"];
145
161
  const fieldNames = project.fields.nodes.map((f) => f.name);
146
162
  const missing = requiredFields.filter((f) => !fieldNames.includes(f));
147
163
  if (missing.length === 0) {
148
- checks.requiredFields = { status: "ok", detail: "All required fields present" };
164
+ checks.requiredFields = {
165
+ status: "ok",
166
+ detail: "All required fields present",
167
+ };
149
168
  }
150
169
  else {
151
- checks.requiredFields = { status: "fail", detail: `Missing fields: ${missing.join(", ")}. Run /ralph-setup.` };
170
+ checks.requiredFields = {
171
+ status: "fail",
172
+ detail: `Missing fields: ${missing.join(", ")}. Run /ralph-setup.`,
173
+ };
152
174
  }
153
175
  }
154
176
  else {
155
- checks.projectAccess = { status: "fail", detail: `Project #${projNum} not found for owner "${projOwner}". Check RALPH_GH_PROJECT_OWNER.` };
177
+ checks.projectAccess = {
178
+ status: "fail",
179
+ detail: `Project #${projNum} not found for owner "${projOwner}". Check RALPH_GH_PROJECT_OWNER.`,
180
+ };
156
181
  }
157
182
  }
158
183
  catch (e) {
159
- checks.projectAccess = { status: "fail", detail: `Project access failed: ${e instanceof Error ? e.message : String(e)}. Token may lack 'project' scope.` };
184
+ checks.projectAccess = {
185
+ status: "fail",
186
+ detail: `Project access failed: ${e instanceof Error ? e.message : String(e)}. Token may lack 'project' scope.`,
187
+ };
160
188
  }
161
189
  }
162
190
  else {
163
- checks.projectAccess = { status: "skip", detail: "RALPH_GH_PROJECT_NUMBER not set" };
191
+ checks.projectAccess = {
192
+ status: "skip",
193
+ detail: "RALPH_GH_PROJECT_NUMBER not set",
194
+ };
164
195
  }
165
196
  // Summary
166
197
  const allOk = Object.values(checks).every((c) => c.status === "ok" || c.status === "skip");
@@ -172,8 +203,10 @@ function registerCoreTools(server, client) {
172
203
  repo: client.config.repo || "(not set)",
173
204
  projectOwner: resolveProjectOwner(client.config) || "(not set)",
174
205
  projectNumber: client.config.projectNumber || "(not set)",
175
- tokenMode: client.config.projectToken && client.config.projectToken !== client.config.token
176
- ? "dual-token" : "single-token",
206
+ tokenMode: client.config.projectToken &&
207
+ client.config.projectToken !== client.config.token
208
+ ? "dual-token"
209
+ : "single-token",
177
210
  },
178
211
  });
179
212
  });
package/dist/lib/cache.js CHANGED
@@ -66,7 +66,9 @@ export class SessionCache {
66
66
  */
67
67
  static queryKey(query, variables) {
68
68
  const normalized = query.replace(/\s+/g, " ").trim();
69
- const varsKey = variables ? JSON.stringify(variables, Object.keys(variables).sort()) : "";
69
+ const varsKey = variables
70
+ ? JSON.stringify(variables, Object.keys(variables).sort())
71
+ : "";
70
72
  return `query:${normalized}:${varsKey}`;
71
73
  }
72
74
  }
@@ -200,7 +200,10 @@ export async function detectGroup(client, owner, repo, seedNumber) {
200
200
  }
201
201
  // Check all discovered issues for dependency targets not yet in the set
202
202
  for (const issue of issueMap.values()) {
203
- for (const depNum of [...issue.blockingNumbers, ...issue.blockedByNumbers]) {
203
+ for (const depNum of [
204
+ ...issue.blockingNumbers,
205
+ ...issue.blockedByNumbers,
206
+ ]) {
204
207
  if (!issueMap.has(depNum)) {
205
208
  expandQueue.push(depNum);
206
209
  }
@@ -270,7 +273,11 @@ export async function detectGroup(client, owner, repo, seedNumber) {
270
273
  };
271
274
  return {
272
275
  groupTickets,
273
- groupPrimary: { id: primary.id, number: primary.number, title: primary.title },
276
+ groupPrimary: {
277
+ id: primary.id,
278
+ number: primary.number,
279
+ title: primary.title,
280
+ },
274
281
  isGroup: groupTickets.length > 1,
275
282
  totalTickets: groupTickets.length,
276
283
  };
@@ -359,7 +366,10 @@ function filterGroupMembers(issueMap, seedNumber) {
359
366
  const issue = issueMap.get(num);
360
367
  if (!issue)
361
368
  continue;
362
- for (const depNum of [...issue.blockingNumbers, ...issue.blockedByNumbers]) {
369
+ for (const depNum of [
370
+ ...issue.blockingNumbers,
371
+ ...issue.blockedByNumbers,
372
+ ]) {
363
373
  if (issueMap.has(depNum) && !members.has(depNum)) {
364
374
  // Only add non-parent issues
365
375
  const depIssue = issueMap.get(depNum);
@@ -84,8 +84,14 @@ export function detectPipelinePosition(issues, isGroup, groupPrimary) {
84
84
  required: isGroup,
85
85
  met: false,
86
86
  blocking: [
87
- ...needsResearch.map((i) => ({ number: i.number, state: i.workflowState })),
88
- ...inResearch.map((i) => ({ number: i.number, state: i.workflowState })),
87
+ ...needsResearch.map((i) => ({
88
+ number: i.number,
89
+ state: i.workflowState,
90
+ })),
91
+ ...inResearch.map((i) => ({
92
+ number: i.number,
93
+ state: i.workflowState,
94
+ })),
89
95
  ],
90
96
  };
91
97
  return buildResult("RESEARCH", `${needsResearch.length} need research, ${inResearch.length} in progress`, issues, isGroup, groupPrimary, convergence);
@@ -141,12 +147,23 @@ export function detectPipelinePosition(issues, isGroup, groupPrimary) {
141
147
  // Helpers
142
148
  // ---------------------------------------------------------------------------
143
149
  function buildResult(phase, reason, issues, isGroup, groupPrimary, convergence) {
150
+ // Derive recommendation from convergence state
151
+ let recommendation;
152
+ if (convergence.met) {
153
+ recommendation = "proceed";
154
+ }
155
+ else if (convergence.blocking.some((b) => b.state === "Human Needed")) {
156
+ recommendation = "escalate";
157
+ }
158
+ else {
159
+ recommendation = "wait";
160
+ }
144
161
  return {
145
162
  phase,
146
163
  reason,
147
164
  remainingPhases: REMAINING_PHASES[phase],
148
165
  issues,
149
- convergence,
166
+ convergence: { ...convergence, recommendation },
150
167
  isGroup,
151
168
  groupPrimary,
152
169
  };
@@ -8,12 +8,12 @@
8
8
  // null means the intent is recognized but ambiguous for that command (multi-path)
9
9
  // undefined (missing key) means the intent isn't mapped for that command
10
10
  const SEMANTIC_INTENTS = {
11
- "__LOCK__": {
11
+ __LOCK__: {
12
12
  ralph_research: "Research in Progress",
13
13
  ralph_plan: "Plan in Progress",
14
14
  ralph_impl: "In Progress",
15
15
  },
16
- "__COMPLETE__": {
16
+ __COMPLETE__: {
17
17
  ralph_triage: null, // multi-path: caller must use direct state
18
18
  ralph_split: "Backlog",
19
19
  ralph_research: "Ready for Plan",
@@ -21,13 +21,19 @@ const SEMANTIC_INTENTS = {
21
21
  ralph_impl: "In Review",
22
22
  ralph_review: "In Progress",
23
23
  },
24
- "__ESCALATE__": { "*": "Human Needed" },
25
- "__CLOSE__": { "*": "Done" },
26
- "__CANCEL__": { "*": "Canceled" },
24
+ __ESCALATE__: { "*": "Human Needed" },
25
+ __CLOSE__: { "*": "Done" },
26
+ __CANCEL__: { "*": "Canceled" },
27
27
  };
28
28
  // --- Per-command allowed output states (valid_output_states ∪ {lock_state}) ---
29
29
  const COMMAND_ALLOWED_STATES = {
30
- ralph_triage: ["Research Needed", "Ready for Plan", "Done", "Canceled", "Human Needed"],
30
+ ralph_triage: [
31
+ "Research Needed",
32
+ "Ready for Plan",
33
+ "Done",
34
+ "Canceled",
35
+ "Human Needed",
36
+ ],
31
37
  ralph_split: ["Backlog"],
32
38
  ralph_research: ["Research in Progress", "Ready for Plan", "Human Needed"],
33
39
  ralph_plan: ["Plan in Progress", "Plan in Review", "Human Needed"],
@@ -7,8 +7,8 @@
7
7
  import { z } from "zod";
8
8
  import { paginateConnection } from "../lib/pagination.js";
9
9
  import { detectGroup } from "../lib/group-detection.js";
10
- import { detectPipelinePosition } from "../lib/pipeline-detection.js";
11
- import { isValidState, VALID_STATES, LOCK_STATES } from "../lib/workflow-states.js";
10
+ import { detectPipelinePosition, } from "../lib/pipeline-detection.js";
11
+ import { isValidState, VALID_STATES, LOCK_STATES, } from "../lib/workflow-states.js";
12
12
  import { resolveState } from "../lib/state-resolution.js";
13
13
  import { toolSuccess, toolError, resolveProjectOwner } from "../types.js";
14
14
  // ---------------------------------------------------------------------------
@@ -168,7 +168,8 @@ async function getCurrentFieldValue(client, fieldCache, owner, repo, issueNumber
168
168
  }
169
169
  }
170
170
  }`, { itemId: projectItemId });
171
- const fieldValue = result.node?.fieldValues?.nodes?.find((fv) => fv.field?.name === fieldName && fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
171
+ const fieldValue = result.node?.fieldValues?.nodes?.find((fv) => fv.field?.name === fieldName &&
172
+ fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
172
173
  return fieldValue?.name;
173
174
  }
174
175
  function resolveConfig(client, args) {
@@ -199,17 +200,44 @@ export function registerIssueTools(server, client, fieldCache) {
199
200
  // -------------------------------------------------------------------------
200
201
  // ralph_hero__list_issues
201
202
  // -------------------------------------------------------------------------
202
- server.tool("ralph_hero__list_issues", "List issues from a GitHub repository, optionally filtered by Workflow State, Estimate, Priority, or label. Returns issues with their project field values.", {
203
- owner: z.string().optional().describe("GitHub owner (user or org). Defaults to GITHUB_OWNER env var"),
204
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
205
- workflowState: z.string().optional().describe("Filter by Workflow State name"),
206
- estimate: z.string().optional().describe("Filter by Estimate (XS, S, M, L, XL)"),
207
- priority: z.string().optional().describe("Filter by Priority (P0, P1, P2, P3)"),
203
+ server.tool("ralph_hero__list_issues", "List issues from a GitHub repository with optional filters. Returns: number, title, state, workflowState, estimate, priority, labels, assignees. Use workflowState filter to find issues in a specific phase. Recovery: if no results, broaden filters or check that issues exist in the project.", {
204
+ owner: z
205
+ .string()
206
+ .optional()
207
+ .describe("GitHub owner (user or org). Defaults to GITHUB_OWNER env var"),
208
+ repo: z
209
+ .string()
210
+ .optional()
211
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
212
+ workflowState: z
213
+ .string()
214
+ .optional()
215
+ .describe("Filter by Workflow State name"),
216
+ estimate: z
217
+ .string()
218
+ .optional()
219
+ .describe("Filter by Estimate (XS, S, M, L, XL)"),
220
+ priority: z
221
+ .string()
222
+ .optional()
223
+ .describe("Filter by Priority (P0, P1, P2, P3)"),
208
224
  label: z.string().optional().describe("Filter by label name"),
209
225
  query: z.string().optional().describe("Additional search query string"),
210
- state: z.enum(["OPEN", "CLOSED"]).optional().default("OPEN").describe("Issue state filter (default: OPEN)"),
211
- orderBy: z.enum(["CREATED_AT", "UPDATED_AT", "COMMENTS"]).optional().default("CREATED_AT").describe("Order by field"),
212
- limit: z.number().optional().default(50).describe("Max items to return (default 50)"),
226
+ state: z
227
+ .enum(["OPEN", "CLOSED"])
228
+ .optional()
229
+ .default("OPEN")
230
+ .describe("Issue state filter (default: OPEN)"),
231
+ orderBy: z
232
+ .enum(["CREATED_AT", "UPDATED_AT", "COMMENTS"])
233
+ .optional()
234
+ .default("CREATED_AT")
235
+ .describe("Order by field"),
236
+ limit: z
237
+ .number()
238
+ .optional()
239
+ .default(50)
240
+ .describe("Max items to return (default 50)"),
213
241
  }, async (args) => {
214
242
  try {
215
243
  const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, args);
@@ -282,7 +310,8 @@ export function registerIssueTools(server, client, fieldCache) {
282
310
  if (args.label) {
283
311
  items = items.filter((item) => {
284
312
  const content = item.content;
285
- const labels = content?.labels?.nodes || [];
313
+ const labels = content?.labels?.nodes ||
314
+ [];
286
315
  return labels.some((l) => l.name === args.label);
287
316
  });
288
317
  }
@@ -336,10 +365,21 @@ export function registerIssueTools(server, client, fieldCache) {
336
365
  // -------------------------------------------------------------------------
337
366
  // ralph_hero__get_issue
338
367
  // -------------------------------------------------------------------------
339
- server.tool("ralph_hero__get_issue", "Get a single GitHub issue with full context: properties, project field values, relationships (parent, sub-issues, blocking, blocked-by), and recent comments", {
340
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
341
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
368
+ server.tool("ralph_hero__get_issue", "Get a single GitHub issue with full context: properties, project field values, relationships (parent, sub-issues, blocking, blocked-by), recent comments, and optional group detection. Returns group data by default so callers don't need a separate detect_group call. Key fields: number, title, workflowState, estimate, priority, parent, subIssues, blocking, blockedBy, comments, group.", {
369
+ owner: z
370
+ .string()
371
+ .optional()
372
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
373
+ repo: z
374
+ .string()
375
+ .optional()
376
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
342
377
  number: z.number().describe("Issue number"),
378
+ includeGroup: z
379
+ .boolean()
380
+ .optional()
381
+ .default(true)
382
+ .describe("Include group detection results (default: true). Set to false to skip group detection and save API calls when group context is not needed."),
343
383
  }, async (args) => {
344
384
  try {
345
385
  const { owner, repo } = resolveConfig(client, args);
@@ -402,7 +442,9 @@ export function registerIssueTools(server, client, fieldCache) {
402
442
  return toolError(`Issue #${args.number} not found in ${owner}/${repo}`);
403
443
  }
404
444
  // Cache the node ID
405
- client.getCache().set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
445
+ client
446
+ .getCache()
447
+ .set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
406
448
  // Extract project field values (find matching project if we know the project number)
407
449
  let workflowState;
408
450
  let estimate;
@@ -412,9 +454,12 @@ export function registerIssueTools(server, client, fieldCache) {
412
454
  : issue.projectItems.nodes[0]; // Use first project item if no project configured
413
455
  if (projectItem) {
414
456
  // Cache the project item ID
415
- client.getCache().set(`project-item-id:${owner}/${repo}#${issue.number}`, projectItem.id, 30 * 60 * 1000);
457
+ client
458
+ .getCache()
459
+ .set(`project-item-id:${owner}/${repo}#${issue.number}`, projectItem.id, 30 * 60 * 1000);
416
460
  for (const fv of projectItem.fieldValues.nodes) {
417
- if (fv.__typename === "ProjectV2ItemFieldSingleSelectValue" && fv.field) {
461
+ if (fv.__typename === "ProjectV2ItemFieldSingleSelectValue" &&
462
+ fv.field) {
418
463
  switch (fv.field.name) {
419
464
  case "Workflow State":
420
465
  workflowState = fv.name;
@@ -429,6 +474,32 @@ export function registerIssueTools(server, client, fieldCache) {
429
474
  }
430
475
  }
431
476
  }
477
+ // Optionally detect group context
478
+ let group = null;
479
+ if (args.includeGroup !== false) {
480
+ try {
481
+ const { owner: cfgOwner, repo: cfgRepo } = resolveConfig(client, args);
482
+ const groupResult = await detectGroup(client, cfgOwner, cfgRepo, args.number);
483
+ group = {
484
+ isGroup: groupResult.isGroup,
485
+ primary: {
486
+ number: groupResult.groupPrimary.number,
487
+ title: groupResult.groupPrimary.title,
488
+ },
489
+ members: groupResult.groupTickets.map((t) => ({
490
+ number: t.number,
491
+ title: t.title,
492
+ state: t.state,
493
+ order: t.order,
494
+ })),
495
+ totalTickets: groupResult.totalTickets,
496
+ };
497
+ }
498
+ catch {
499
+ // Group detection is best-effort; don't fail the whole request
500
+ group = null;
501
+ }
502
+ }
432
503
  return toolSuccess({
433
504
  number: issue.number,
434
505
  id: issue.id,
@@ -446,7 +517,11 @@ export function registerIssueTools(server, client, fieldCache) {
446
517
  labels: issue.labels.nodes.map((l) => l.name),
447
518
  assignees: issue.assignees.nodes.map((a) => a.login),
448
519
  parent: issue.parent
449
- ? { number: issue.parent.number, title: issue.parent.title, state: issue.parent.state }
520
+ ? {
521
+ number: issue.parent.number,
522
+ title: issue.parent.title,
523
+ state: issue.parent.state,
524
+ }
450
525
  : null,
451
526
  subIssuesSummary: issue.subIssuesSummary,
452
527
  subIssues: issue.subIssues.nodes.map((si) => ({
@@ -470,6 +545,7 @@ export function registerIssueTools(server, client, fieldCache) {
470
545
  author: c.author?.login || "unknown",
471
546
  createdAt: c.createdAt,
472
547
  })),
548
+ group,
473
549
  });
474
550
  }
475
551
  catch (error) {
@@ -480,14 +556,26 @@ export function registerIssueTools(server, client, fieldCache) {
480
556
  // -------------------------------------------------------------------------
481
557
  // ralph_hero__create_issue
482
558
  // -------------------------------------------------------------------------
483
- server.tool("ralph_hero__create_issue", "Create a GitHub issue and add it to the project with optional field values (Workflow State, Estimate, Priority)", {
484
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
485
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
559
+ server.tool("ralph_hero__create_issue", "Create a GitHub issue and add it to the project with optional field values. Returns: number, id, title, url, projectItemId, fieldsSet. Recovery: if field value fails, verify the option name matches exactly (case-sensitive).", {
560
+ owner: z
561
+ .string()
562
+ .optional()
563
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
564
+ repo: z
565
+ .string()
566
+ .optional()
567
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
486
568
  title: z.string().describe("Issue title"),
487
569
  body: z.string().optional().describe("Issue body (Markdown)"),
488
570
  labels: z.array(z.string()).optional().describe("Label names to apply"),
489
- assignees: z.array(z.string()).optional().describe("GitHub usernames to assign"),
490
- workflowState: z.string().optional().describe("Initial Workflow State name"),
571
+ assignees: z
572
+ .array(z.string())
573
+ .optional()
574
+ .describe("GitHub usernames to assign"),
575
+ workflowState: z
576
+ .string()
577
+ .optional()
578
+ .describe("Initial Workflow State name"),
491
579
  estimate: z.string().optional().describe("Estimate (XS, S, M, L, XL)"),
492
580
  priority: z.string().optional().describe("Priority (P0, P1, P2, P3)"),
493
581
  }, async (args) => {
@@ -543,7 +631,9 @@ export function registerIssueTools(server, client, fieldCache) {
543
631
  });
544
632
  const issue = createResult.createIssue.issue;
545
633
  // Cache the node ID
546
- client.getCache().set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
634
+ client
635
+ .getCache()
636
+ .set(`issue-node-id:${owner}/${repo}#${issue.number}`, issue.id, 30 * 60 * 1000);
547
637
  // Step 4: Add to project
548
638
  const projectId = fieldCache.getProjectId();
549
639
  if (!projectId) {
@@ -559,7 +649,9 @@ export function registerIssueTools(server, client, fieldCache) {
559
649
  }`, { projectId, contentId: issue.id });
560
650
  const projectItemId = addResult.addProjectV2ItemById.item.id;
561
651
  // Cache project item ID
562
- client.getCache().set(`project-item-id:${owner}/${repo}#${issue.number}`, projectItemId, 30 * 60 * 1000);
652
+ client
653
+ .getCache()
654
+ .set(`project-item-id:${owner}/${repo}#${issue.number}`, projectItemId, 30 * 60 * 1000);
563
655
  // Step 5: Set field values
564
656
  if (args.workflowState) {
565
657
  await updateProjectItemField(client, fieldCache, projectItemId, "Workflow State", args.workflowState);
@@ -591,14 +683,26 @@ export function registerIssueTools(server, client, fieldCache) {
591
683
  // -------------------------------------------------------------------------
592
684
  // ralph_hero__update_issue
593
685
  // -------------------------------------------------------------------------
594
- server.tool("ralph_hero__update_issue", "Update a GitHub issue's basic properties (title, body, labels, assignees)", {
595
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
596
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
686
+ server.tool("ralph_hero__update_issue", "Update a GitHub issue's basic properties (title, body, labels, assignees). Returns: number, title, url. Use update_workflow_state for state changes, update_estimate for estimates, update_priority for priorities.", {
687
+ owner: z
688
+ .string()
689
+ .optional()
690
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
691
+ repo: z
692
+ .string()
693
+ .optional()
694
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
597
695
  number: z.number().describe("Issue number"),
598
696
  title: z.string().optional().describe("New issue title"),
599
697
  body: z.string().optional().describe("New issue body (Markdown)"),
600
- labels: z.array(z.string()).optional().describe("Label names (replaces existing labels)"),
601
- assignees: z.array(z.string()).optional().describe("GitHub usernames to assign (replaces existing)"),
698
+ labels: z
699
+ .array(z.string())
700
+ .optional()
701
+ .describe("Label names (replaces existing labels)"),
702
+ assignees: z
703
+ .array(z.string())
704
+ .optional()
705
+ .describe("GitHub usernames to assign (replaces existing)"),
602
706
  }, async (args) => {
603
707
  try {
604
708
  const { owner, repo } = resolveConfig(client, args);
@@ -653,13 +757,23 @@ export function registerIssueTools(server, client, fieldCache) {
653
757
  // -------------------------------------------------------------------------
654
758
  // ralph_hero__update_workflow_state
655
759
  // -------------------------------------------------------------------------
656
- server.tool("ralph_hero__update_workflow_state", "Change an issue's Workflow State. Accepts semantic intents (__LOCK__, __COMPLETE__, __ESCALATE__, __CLOSE__, __CANCEL__) or direct state names. The command parameter enables validation.", {
657
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
658
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
760
+ server.tool("ralph_hero__update_workflow_state", "Change an issue's Workflow State using semantic intents or direct state names. Returns: number, previousState, newState, command. Semantic intents: __LOCK__ (lock for processing), __COMPLETE__ (mark done), __ESCALATE__ (needs human), __CLOSE__, __CANCEL__. Recovery: if state transition fails, verify the issue is in the project and the state name is valid.", {
761
+ owner: z
762
+ .string()
763
+ .optional()
764
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
765
+ repo: z
766
+ .string()
767
+ .optional()
768
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
659
769
  number: z.number().describe("Issue number"),
660
- state: z.string().describe("Target state: semantic intent (__LOCK__, __COMPLETE__, __ESCALATE__, __CLOSE__, __CANCEL__) " +
770
+ state: z
771
+ .string()
772
+ .describe("Target state: semantic intent (__LOCK__, __COMPLETE__, __ESCALATE__, __CLOSE__, __CANCEL__) " +
661
773
  "or direct state name (e.g., 'Research Needed', 'In Progress')"),
662
- command: z.string().describe("Ralph command making this transition (e.g., 'ralph_research', 'ralph_plan'). " +
774
+ command: z
775
+ .string()
776
+ .describe("Ralph command making this transition (e.g., 'ralph_research', 'ralph_plan'). " +
663
777
  "Required for validation and semantic intent resolution."),
664
778
  }, async (args) => {
665
779
  try {
@@ -693,9 +807,15 @@ export function registerIssueTools(server, client, fieldCache) {
693
807
  // -------------------------------------------------------------------------
694
808
  // ralph_hero__update_estimate
695
809
  // -------------------------------------------------------------------------
696
- server.tool("ralph_hero__update_estimate", "Change an issue's Estimate in the project (XS, S, M, L, XL)", {
697
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
698
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
810
+ server.tool("ralph_hero__update_estimate", "Change an issue's Estimate in the project. Returns: number, estimate. Valid values: XS, S, M, L, XL. Recovery: if the issue is not in the project, add it first via create_issue.", {
811
+ owner: z
812
+ .string()
813
+ .optional()
814
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
815
+ repo: z
816
+ .string()
817
+ .optional()
818
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
699
819
  number: z.number().describe("Issue number"),
700
820
  estimate: z.string().describe("Estimate value (XS, S, M, L, XL)"),
701
821
  }, async (args) => {
@@ -717,9 +837,15 @@ export function registerIssueTools(server, client, fieldCache) {
717
837
  // -------------------------------------------------------------------------
718
838
  // ralph_hero__update_priority
719
839
  // -------------------------------------------------------------------------
720
- server.tool("ralph_hero__update_priority", "Change an issue's Priority in the project (P0, P1, P2, P3)", {
721
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
722
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
840
+ server.tool("ralph_hero__update_priority", "Change an issue's Priority in the project. Returns: number, priority. Valid values: P0, P1, P2, P3. Recovery: if the issue is not in the project, add it first via create_issue.", {
841
+ owner: z
842
+ .string()
843
+ .optional()
844
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
845
+ repo: z
846
+ .string()
847
+ .optional()
848
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
723
849
  number: z.number().describe("Issue number"),
724
850
  priority: z.string().describe("Priority value (P0, P1, P2, P3)"),
725
851
  }, async (args) => {
@@ -741,9 +867,15 @@ export function registerIssueTools(server, client, fieldCache) {
741
867
  // -------------------------------------------------------------------------
742
868
  // ralph_hero__create_comment
743
869
  // -------------------------------------------------------------------------
744
- server.tool("ralph_hero__create_comment", "Add a comment to a GitHub issue", {
745
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
746
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
870
+ server.tool("ralph_hero__create_comment", "Add a comment to a GitHub issue. Returns: commentId, issueNumber. Recovery: if issue not found, verify the issue number exists in the repository.", {
871
+ owner: z
872
+ .string()
873
+ .optional()
874
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
875
+ repo: z
876
+ .string()
877
+ .optional()
878
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
747
879
  number: z.number().describe("Issue number"),
748
880
  body: z.string().describe("Comment body (Markdown)"),
749
881
  }, async (args) => {
@@ -773,9 +905,15 @@ export function registerIssueTools(server, client, fieldCache) {
773
905
  // -------------------------------------------------------------------------
774
906
  // ralph_hero__detect_pipeline_position
775
907
  // -------------------------------------------------------------------------
776
- server.tool("ralph_hero__detect_pipeline_position", "Determine the current pipeline position for an issue or group. Returns the phase to execute and remaining phases. Replaces manual interpretation of workflow state tables.", {
777
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
778
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
908
+ server.tool("ralph_hero__detect_pipeline_position", "Determine which workflow phase to execute next for an issue or its group. Returns: phase (SPLIT/TRIAGE/RESEARCH/PLAN/REVIEW/IMPLEMENT/COMPLETE/HUMAN_GATE/TERMINAL), convergence status with recommendation (proceed/wait/escalate), all group member states, and remaining phases. Call this INSTEAD of separate detect_group + check_convergence calls. Recovery: if issue not found, verify the issue number and that it has been added to the project.", {
909
+ owner: z
910
+ .string()
911
+ .optional()
912
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
913
+ repo: z
914
+ .string()
915
+ .optional()
916
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
779
917
  number: z.number().describe("Issue number (seed for group detection)"),
780
918
  }, async (args) => {
781
919
  try {
@@ -812,11 +950,19 @@ export function registerIssueTools(server, client, fieldCache) {
812
950
  // -------------------------------------------------------------------------
813
951
  // ralph_hero__check_convergence
814
952
  // -------------------------------------------------------------------------
815
- server.tool("ralph_hero__check_convergence", "Check if all issues in a group have reached the required state for the next phase. Returns convergence status with details on blocking issues.", {
816
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
817
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
953
+ server.tool("ralph_hero__check_convergence", "Check if all issues in a group have reached the required state for the next phase. Returns: converged, targetState, total, ready, blocking (with distanceToTarget), recommendation (proceed/wait/escalate). Note: detect_pipeline_position already includes convergence data; use this only when checking convergence against a specific target state not covered by pipeline detection.", {
954
+ owner: z
955
+ .string()
956
+ .optional()
957
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
958
+ repo: z
959
+ .string()
960
+ .optional()
961
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
818
962
  number: z.number().describe("Issue number (any issue in the group)"),
819
- targetState: z.string().describe("The state all issues must be in (e.g., 'Ready for Plan')"),
963
+ targetState: z
964
+ .string()
965
+ .describe("The state all issues must be in (e.g., 'Ready for Plan')"),
820
966
  }, async (args) => {
821
967
  try {
822
968
  // Validate target state
@@ -839,12 +985,16 @@ export function registerIssueTools(server, client, fieldCache) {
839
985
  targetState: args.targetState,
840
986
  total: 1,
841
987
  ready: atTarget ? 1 : 0,
842
- blocking: atTarget ? [] : [{
843
- number: args.number,
844
- title: group.groupPrimary.title,
845
- currentState: state.workflowState || "unknown",
846
- distanceToTarget: computeDistance(state.workflowState || "unknown", args.targetState),
847
- }],
988
+ blocking: atTarget
989
+ ? []
990
+ : [
991
+ {
992
+ number: args.number,
993
+ title: group.groupPrimary.title,
994
+ currentState: state.workflowState || "unknown",
995
+ distanceToTarget: computeDistance(state.workflowState || "unknown", args.targetState),
996
+ },
997
+ ],
848
998
  recommendation: atTarget ? "proceed" : "wait",
849
999
  });
850
1000
  }
@@ -895,11 +1045,23 @@ export function registerIssueTools(server, client, fieldCache) {
895
1045
  // -------------------------------------------------------------------------
896
1046
  // ralph_hero__pick_actionable_issue
897
1047
  // -------------------------------------------------------------------------
898
- server.tool("ralph_hero__pick_actionable_issue", "Find the highest-priority issue that matches the given workflow state and is not blocked or locked. Used by dispatch loop to find work for idle teammates.", {
899
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
900
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
901
- workflowState: z.string().describe("Target workflow state (e.g., 'Research Needed', 'Ready for Plan')"),
902
- maxEstimate: z.string().optional().default("S").describe("Maximum estimate to include (XS, S, M, L, XL). Default: S"),
1048
+ server.tool("ralph_hero__pick_actionable_issue", "Find the highest-priority issue matching a workflow state that is not blocked or locked. Returns: found, issue (with number, title, workflowState, estimate, priority, group context), alternatives count. Used by dispatch loop to find work for idle teammates. Recovery: if no issues found, try a different workflowState or increase maxEstimate.", {
1049
+ owner: z
1050
+ .string()
1051
+ .optional()
1052
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
1053
+ repo: z
1054
+ .string()
1055
+ .optional()
1056
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
1057
+ workflowState: z
1058
+ .string()
1059
+ .describe("Target workflow state (e.g., 'Research Needed', 'Ready for Plan')"),
1060
+ maxEstimate: z
1061
+ .string()
1062
+ .optional()
1063
+ .default("S")
1064
+ .describe("Maximum estimate to include (XS, S, M, L, XL). Default: S"),
903
1065
  }, async (args) => {
904
1066
  try {
905
1067
  // Validate workflow state
@@ -999,7 +1161,12 @@ export function registerIssueTools(server, client, fieldCache) {
999
1161
  return !blockedBy.nodes.some((dep) => dep.state === "OPEN");
1000
1162
  });
1001
1163
  // Sort by priority (P0 > P1 > P2 > P3 > none)
1002
- const priorityOrder = { P0: 0, P1: 1, P2: 2, P3: 3 };
1164
+ const priorityOrder = {
1165
+ P0: 0,
1166
+ P1: 1,
1167
+ P2: 2,
1168
+ P3: 3,
1169
+ };
1003
1170
  candidates.sort((a, b) => {
1004
1171
  const pA = getFieldValue(a, "Priority");
1005
1172
  const pB = getFieldValue(b, "Priority");
@@ -1016,10 +1183,34 @@ export function registerIssueTools(server, client, fieldCache) {
1016
1183
  }
1017
1184
  const best = candidates[0];
1018
1185
  const content = best.content;
1186
+ const issueNumber = content.number;
1187
+ // Detect group context for the picked issue (best-effort)
1188
+ let group = null;
1189
+ try {
1190
+ const groupResult = await detectGroup(client, owner, repo, issueNumber);
1191
+ group = {
1192
+ isGroup: groupResult.isGroup,
1193
+ primary: {
1194
+ number: groupResult.groupPrimary.number,
1195
+ title: groupResult.groupPrimary.title,
1196
+ },
1197
+ members: groupResult.groupTickets.map((t) => ({
1198
+ number: t.number,
1199
+ title: t.title,
1200
+ state: t.state,
1201
+ order: t.order,
1202
+ })),
1203
+ totalTickets: groupResult.totalTickets,
1204
+ };
1205
+ }
1206
+ catch {
1207
+ // Group detection is best-effort
1208
+ group = null;
1209
+ }
1019
1210
  return toolSuccess({
1020
1211
  found: true,
1021
1212
  issue: {
1022
- number: content.number,
1213
+ number: issueNumber,
1023
1214
  title: content.title,
1024
1215
  description: content.body || "",
1025
1216
  workflowState: getFieldValue(best, "Workflow State"),
@@ -1028,6 +1219,7 @@ export function registerIssueTools(server, client, fieldCache) {
1028
1219
  isLocked: false,
1029
1220
  blockedBy: [],
1030
1221
  },
1222
+ group,
1031
1223
  alternatives: candidates.length - 1,
1032
1224
  });
1033
1225
  }
@@ -1038,7 +1230,8 @@ export function registerIssueTools(server, client, fieldCache) {
1038
1230
  });
1039
1231
  }
1040
1232
  function getFieldValue(item, fieldName) {
1041
- const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName && fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
1233
+ const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName &&
1234
+ fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
1042
1235
  return fieldValue?.name;
1043
1236
  }
1044
1237
  // ---------------------------------------------------------------------------
@@ -10,19 +10,59 @@ import { toolSuccess, toolError } from "../types.js";
10
10
  import { resolveProjectOwner } from "../types.js";
11
11
  const WORKFLOW_STATE_OPTIONS = [
12
12
  { name: "Backlog", color: "GRAY", description: "Awaiting triage" },
13
- { name: "Research Needed", color: "PURPLE", description: "Needs investigation before planning" },
14
- { name: "Research in Progress", color: "PURPLE", description: "Investigation underway (locked)" },
15
- { name: "Ready for Plan", color: "BLUE", description: "Research complete, ready for planning" },
16
- { name: "Plan in Progress", color: "BLUE", description: "Plan being written (locked)" },
17
- { name: "Plan in Review", color: "BLUE", description: "Plan awaiting approval" },
18
- { name: "In Progress", color: "ORANGE", description: "Implementation underway" },
19
- { name: "In Review", color: "YELLOW", description: "PR created, awaiting code review" },
13
+ {
14
+ name: "Research Needed",
15
+ color: "PURPLE",
16
+ description: "Needs investigation before planning",
17
+ },
18
+ {
19
+ name: "Research in Progress",
20
+ color: "PURPLE",
21
+ description: "Investigation underway (locked)",
22
+ },
23
+ {
24
+ name: "Ready for Plan",
25
+ color: "BLUE",
26
+ description: "Research complete, ready for planning",
27
+ },
28
+ {
29
+ name: "Plan in Progress",
30
+ color: "BLUE",
31
+ description: "Plan being written (locked)",
32
+ },
33
+ {
34
+ name: "Plan in Review",
35
+ color: "BLUE",
36
+ description: "Plan awaiting approval",
37
+ },
38
+ {
39
+ name: "In Progress",
40
+ color: "ORANGE",
41
+ description: "Implementation underway",
42
+ },
43
+ {
44
+ name: "In Review",
45
+ color: "YELLOW",
46
+ description: "PR created, awaiting code review",
47
+ },
20
48
  { name: "Done", color: "GREEN", description: "Completed and merged" },
21
- { name: "Human Needed", color: "RED", description: "Escalated - requires human intervention" },
22
- { name: "Canceled", color: "GRAY", description: "Ticket canceled or superseded" },
49
+ {
50
+ name: "Human Needed",
51
+ color: "RED",
52
+ description: "Escalated - requires human intervention",
53
+ },
54
+ {
55
+ name: "Canceled",
56
+ color: "GRAY",
57
+ description: "Ticket canceled or superseded",
58
+ },
23
59
  ];
24
60
  const PRIORITY_OPTIONS = [
25
- { name: "P0", color: "RED", description: "Critical - Drop everything, fix now" },
61
+ {
62
+ name: "P0",
63
+ color: "RED",
64
+ description: "Critical - Drop everything, fix now",
65
+ },
26
66
  { name: "P1", color: "ORANGE", description: "High - Must do this sprint" },
27
67
  { name: "P2", color: "YELLOW", description: "Medium - Should do soon" },
28
68
  { name: "P3", color: "GRAY", description: "Low - Nice to have" },
@@ -138,8 +178,14 @@ export function registerProjectTools(server, client, fieldCache) {
138
178
  // ralph_hero__get_project
139
179
  // -------------------------------------------------------------------------
140
180
  server.tool("ralph_hero__get_project", "Get a GitHub Project V2 with all fields and their options", {
141
- owner: z.string().optional().describe("GitHub owner (user or org). Defaults to GITHUB_OWNER env var"),
142
- number: z.number().optional().describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
181
+ owner: z
182
+ .string()
183
+ .optional()
184
+ .describe("GitHub owner (user or org). Defaults to GITHUB_OWNER env var"),
185
+ number: z
186
+ .number()
187
+ .optional()
188
+ .describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
143
189
  }, async (args) => {
144
190
  try {
145
191
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -185,12 +231,31 @@ export function registerProjectTools(server, client, fieldCache) {
185
231
  // ralph_hero__list_project_items
186
232
  // -------------------------------------------------------------------------
187
233
  server.tool("ralph_hero__list_project_items", "List items in a GitHub Project V2, optionally filtered by Workflow State, Estimate, or Priority", {
188
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
189
- number: z.number().optional().describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
190
- workflowState: z.string().optional().describe("Filter by Workflow State name"),
191
- estimate: z.string().optional().describe("Filter by Estimate name (XS, S, M, L, XL)"),
192
- priority: z.string().optional().describe("Filter by Priority name (P0, P1, P2, P3)"),
193
- limit: z.number().optional().default(50).describe("Max items to return (default 50)"),
234
+ owner: z
235
+ .string()
236
+ .optional()
237
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
238
+ number: z
239
+ .number()
240
+ .optional()
241
+ .describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
242
+ workflowState: z
243
+ .string()
244
+ .optional()
245
+ .describe("Filter by Workflow State name"),
246
+ estimate: z
247
+ .string()
248
+ .optional()
249
+ .describe("Filter by Estimate name (XS, S, M, L, XL)"),
250
+ priority: z
251
+ .string()
252
+ .optional()
253
+ .describe("Filter by Priority name (P0, P1, P2, P3)"),
254
+ limit: z
255
+ .number()
256
+ .optional()
257
+ .default(50)
258
+ .describe("Max items to return (default 50)"),
194
259
  }, async (args) => {
195
260
  try {
196
261
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -303,7 +368,8 @@ export function registerProjectTools(server, client, fieldCache) {
303
368
  });
304
369
  }
305
370
  function getFieldValue(item, fieldName) {
306
- const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName && fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
371
+ const fieldValue = item.fieldValues.nodes.find((fv) => fv.field?.name === fieldName &&
372
+ fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
307
373
  return fieldValue?.name;
308
374
  }
309
375
  async function fetchProject(client, owner, number) {
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { z } from "zod";
11
11
  import { detectGroup } from "../lib/group-detection.js";
12
- import { isValidState, isEarlierState, VALID_STATES } from "../lib/workflow-states.js";
12
+ import { isValidState, isEarlierState, VALID_STATES, } from "../lib/workflow-states.js";
13
13
  import { toolSuccess, toolError, resolveProjectOwner } from "../types.js";
14
14
  // ---------------------------------------------------------------------------
15
15
  // Helper: Resolve issue number to node ID (with caching)
@@ -51,11 +51,23 @@ export function registerRelationshipTools(server, client, fieldCache) {
51
51
  // ralph_hero__add_sub_issue
52
52
  // -------------------------------------------------------------------------
53
53
  server.tool("ralph_hero__add_sub_issue", "Create a parent/child (sub-issue) relationship between two GitHub issues. The parent issue becomes the container for the child issue.", {
54
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
55
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
54
+ owner: z
55
+ .string()
56
+ .optional()
57
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
58
+ repo: z
59
+ .string()
60
+ .optional()
61
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
56
62
  parentNumber: z.number().describe("Parent issue number"),
57
- childNumber: z.number().describe("Child issue number (will become sub-issue of parent)"),
58
- replaceParent: z.boolean().optional().default(false).describe("If true, move child even if it already has a parent"),
63
+ childNumber: z
64
+ .number()
65
+ .describe("Child issue number (will become sub-issue of parent)"),
66
+ replaceParent: z
67
+ .boolean()
68
+ .optional()
69
+ .default(false)
70
+ .describe("If true, move child even if it already has a parent"),
59
71
  }, async (args) => {
60
72
  try {
61
73
  const { owner, repo } = resolveConfig(client, args);
@@ -93,8 +105,14 @@ export function registerRelationshipTools(server, client, fieldCache) {
93
105
  // ralph_hero__list_sub_issues
94
106
  // -------------------------------------------------------------------------
95
107
  server.tool("ralph_hero__list_sub_issues", "List all sub-issues (children) of a parent GitHub issue, with completion summary", {
96
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
97
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
108
+ owner: z
109
+ .string()
110
+ .optional()
111
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
112
+ repo: z
113
+ .string()
114
+ .optional()
115
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
98
116
  number: z.number().describe("Parent issue number"),
99
117
  }, async (args) => {
100
118
  try {
@@ -133,7 +151,8 @@ export function registerRelationshipTools(server, client, fieldCache) {
133
151
  total: issue.subIssues.nodes.length,
134
152
  completed: issue.subIssues.nodes.filter((si) => si.state === "CLOSED").length,
135
153
  percentCompleted: issue.subIssues.nodes.length > 0
136
- ? Math.round((issue.subIssues.nodes.filter((si) => si.state === "CLOSED").length /
154
+ ? Math.round((issue.subIssues.nodes.filter((si) => si.state === "CLOSED")
155
+ .length /
137
156
  issue.subIssues.nodes.length) *
138
157
  100)
139
158
  : 0,
@@ -150,10 +169,20 @@ export function registerRelationshipTools(server, client, fieldCache) {
150
169
  // ralph_hero__add_dependency
151
170
  // -------------------------------------------------------------------------
152
171
  server.tool("ralph_hero__add_dependency", "Create a blocking dependency between two GitHub issues. The 'blockingNumber' issue blocks the 'blockedNumber' issue.", {
153
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
154
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
155
- blockedNumber: z.number().describe("Issue number that IS blocked (cannot proceed until blocker is done)"),
156
- blockingNumber: z.number().describe("Issue number that IS the blocker (must be completed first)"),
172
+ owner: z
173
+ .string()
174
+ .optional()
175
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
176
+ repo: z
177
+ .string()
178
+ .optional()
179
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
180
+ blockedNumber: z
181
+ .number()
182
+ .describe("Issue number that IS blocked (cannot proceed until blocker is done)"),
183
+ blockingNumber: z
184
+ .number()
185
+ .describe("Issue number that IS the blocker (must be completed first)"),
157
186
  }, async (args) => {
158
187
  try {
159
188
  const { owner, repo } = resolveConfig(client, args);
@@ -190,8 +219,14 @@ export function registerRelationshipTools(server, client, fieldCache) {
190
219
  // ralph_hero__remove_dependency
191
220
  // -------------------------------------------------------------------------
192
221
  server.tool("ralph_hero__remove_dependency", "Remove a blocking dependency between two GitHub issues", {
193
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
194
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
222
+ owner: z
223
+ .string()
224
+ .optional()
225
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
226
+ repo: z
227
+ .string()
228
+ .optional()
229
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
195
230
  blockedNumber: z.number().describe("Issue number that was blocked"),
196
231
  blockingNumber: z.number().describe("Issue number that was the blocker"),
197
232
  }, async (args) => {
@@ -230,8 +265,14 @@ export function registerRelationshipTools(server, client, fieldCache) {
230
265
  // ralph_hero__list_dependencies
231
266
  // -------------------------------------------------------------------------
232
267
  server.tool("ralph_hero__list_dependencies", "List all dependencies (blocking and blocked-by) for a GitHub issue", {
233
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
234
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
268
+ owner: z
269
+ .string()
270
+ .optional()
271
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
272
+ repo: z
273
+ .string()
274
+ .optional()
275
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
235
276
  number: z.number().describe("Issue number"),
236
277
  }, async (args) => {
237
278
  try {
@@ -290,9 +331,17 @@ export function registerRelationshipTools(server, client, fieldCache) {
290
331
  // ralph_hero__detect_group
291
332
  // -------------------------------------------------------------------------
292
333
  server.tool("ralph_hero__detect_group", "Detect the group of related issues by traversing sub-issues and dependencies transitively from a seed issue. Returns all group members in topological order (blockers first). Used by Ralph workflow to discover atomic implementation groups.", {
293
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
294
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
295
- number: z.number().describe("Seed issue number to start group detection from"),
334
+ owner: z
335
+ .string()
336
+ .optional()
337
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
338
+ repo: z
339
+ .string()
340
+ .optional()
341
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
342
+ number: z
343
+ .number()
344
+ .describe("Seed issue number to start group detection from"),
296
345
  }, async (args) => {
297
346
  try {
298
347
  const { owner, repo } = resolveConfig(client, args);
@@ -308,10 +357,18 @@ export function registerRelationshipTools(server, client, fieldCache) {
308
357
  // ralph_hero__advance_children
309
358
  // -------------------------------------------------------------------------
310
359
  server.tool("ralph_hero__advance_children", "Advance all child/sub-issues of a parent to match the parent's new state. Only advances children that are in earlier workflow states. Returns what changed, what was skipped, and any errors.", {
311
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
312
- repo: z.string().optional().describe("Repository name. Defaults to GITHUB_REPO env var"),
360
+ owner: z
361
+ .string()
362
+ .optional()
363
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
364
+ repo: z
365
+ .string()
366
+ .optional()
367
+ .describe("Repository name. Defaults to GITHUB_REPO env var"),
313
368
  number: z.number().describe("Parent issue number"),
314
- targetState: z.string().describe("State to advance children to (e.g., 'Research Needed', 'Ready for Plan')"),
369
+ targetState: z
370
+ .string()
371
+ .describe("State to advance children to (e.g., 'Research Needed', 'Ready for Plan')"),
315
372
  }, async (args) => {
316
373
  try {
317
374
  // Validate target state
@@ -506,7 +563,8 @@ async function getCurrentFieldValueForRelationships(client, fieldCache, owner, r
506
563
  }
507
564
  }
508
565
  }`, { itemId: projectItemId });
509
- const fieldValue = result.node?.fieldValues?.nodes?.find((fv) => fv.field?.name === "Workflow State" && fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
566
+ const fieldValue = result.node?.fieldValues?.nodes?.find((fv) => fv.field?.name === "Workflow State" &&
567
+ fv.__typename === "ProjectV2ItemFieldSingleSelectValue");
510
568
  return fieldValue?.name;
511
569
  }
512
570
  async function updateProjectItemFieldForRelationships(client, fieldCache, projectItemId, fieldName, optionName) {
@@ -17,8 +17,14 @@ export function registerViewTools(server, client, fieldCache) {
17
17
  // ralph_hero__list_views
18
18
  // -------------------------------------------------------------------------
19
19
  server.tool("ralph_hero__list_views", "List all views for a GitHub Project V2", {
20
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
21
- number: z.number().optional().describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
20
+ owner: z
21
+ .string()
22
+ .optional()
23
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
24
+ number: z
25
+ .number()
26
+ .optional()
27
+ .describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
22
28
  }, async (args) => {
23
29
  try {
24
30
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -52,14 +58,35 @@ export function registerViewTools(server, client, fieldCache) {
52
58
  // ralph_hero__update_field_options
53
59
  // -------------------------------------------------------------------------
54
60
  server.tool("ralph_hero__update_field_options", "Update a single-select field's options (names, colors, descriptions). Overwrites ALL existing options — include unchanged options to preserve them.", {
55
- owner: z.string().optional().describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
56
- number: z.number().optional().describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
57
- fieldName: z.string().describe("Name of the single-select field to update (e.g., 'Workflow State', 'Priority', 'Estimate')"),
58
- options: z.array(z.object({
61
+ owner: z
62
+ .string()
63
+ .optional()
64
+ .describe("GitHub owner. Defaults to GITHUB_OWNER env var"),
65
+ number: z
66
+ .number()
67
+ .optional()
68
+ .describe("Project number. Defaults to RALPH_GH_PROJECT_NUMBER env var"),
69
+ fieldName: z
70
+ .string()
71
+ .describe("Name of the single-select field to update (e.g., 'Workflow State', 'Priority', 'Estimate')"),
72
+ options: z
73
+ .array(z.object({
59
74
  name: z.string().describe("Option name"),
60
- color: z.enum(["GRAY", "BLUE", "GREEN", "YELLOW", "ORANGE", "RED", "PINK", "PURPLE"]).describe("Display color"),
75
+ color: z
76
+ .enum([
77
+ "GRAY",
78
+ "BLUE",
79
+ "GREEN",
80
+ "YELLOW",
81
+ "ORANGE",
82
+ "RED",
83
+ "PINK",
84
+ "PURPLE",
85
+ ])
86
+ .describe("Display color"),
61
87
  description: z.string().describe("Description text"),
62
- })).describe("Complete list of options (replaces all existing options)"),
88
+ }))
89
+ .describe("Complete list of options (replaces all existing options)"),
63
90
  }, async (args) => {
64
91
  try {
65
92
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -144,7 +171,11 @@ async function ensureFieldCache(client, fieldCache, owner, projectNumber) {
144
171
  const result = await client.projectQuery(QUERY.replace("OWNER_TYPE", ownerType), { owner, number: projectNumber }, { cache: true });
145
172
  const project = result[ownerType]?.projectV2;
146
173
  if (project) {
147
- fieldCache.populate(project.id, project.fields.nodes.map((f) => ({ id: f.id, name: f.name, options: f.options })));
174
+ fieldCache.populate(project.id, project.fields.nodes.map((f) => ({
175
+ id: f.id,
176
+ name: f.name,
177
+ options: f.options,
178
+ })));
148
179
  return;
149
180
  }
150
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",