ralph-hero-mcp-server 2.5.2 → 2.5.3

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
@@ -21,6 +21,7 @@ import { registerBatchTools } from "./tools/batch-tools.js";
21
21
  import { registerProjectManagementTools } from "./tools/project-management-tools.js";
22
22
  import { registerHygieneTools } from "./tools/hygiene-tools.js";
23
23
  import { registerDebugTools } from "./tools/debug-tools.js";
24
+ import { registerDecomposeTools } from "./tools/decompose-tools.js";
24
25
  /**
25
26
  * Initialize the GitHub client from environment variables.
26
27
  */
@@ -244,6 +245,17 @@ async function main() {
244
245
  console.error("[ralph-hero] Debug logging enabled (RALPH_DEBUG=true)");
245
246
  }
246
247
  const client = initGitHubClient(debugLogger);
248
+ // Load repo registry (.ralph-repos.yml) before repo inference (non-fatal)
249
+ try {
250
+ const { loadRepoRegistry } = await import("./lib/registry-loader.js");
251
+ const registry = await loadRepoRegistry(client);
252
+ if (registry) {
253
+ client.config.repoRegistry = registry;
254
+ }
255
+ }
256
+ catch (e) {
257
+ console.error(`[ralph-hero] Repo registry load skipped: ${e instanceof Error ? e.message : String(e)}`);
258
+ }
247
259
  // Attempt lazy repo inference from project (non-fatal)
248
260
  try {
249
261
  await resolveRepoFromProject(client);
@@ -287,6 +299,8 @@ async function main() {
287
299
  registerProjectManagementTools(server, client, fieldCache);
288
300
  // Hygiene reporting tools
289
301
  registerHygieneTools(server, client, fieldCache);
302
+ // Decompose feature tool (cross-repo decomposition via .ralph-repos.yml)
303
+ registerDecomposeTools(server, client, fieldCache);
290
304
  // Debug tools (only when RALPH_DEBUG=true)
291
305
  if (process.env.RALPH_DEBUG === 'true') {
292
306
  registerDebugTools(server, client);
@@ -618,6 +618,23 @@ export function formatMarkdown(data, issuesPerPhase = 10) {
618
618
  }
619
619
  return lines.join("\n");
620
620
  }
621
+ // ---------------------------------------------------------------------------
622
+ // groupDashboardItemsByRepo
623
+ // ---------------------------------------------------------------------------
624
+ /**
625
+ * Group dashboard items by repository (nameWithOwner).
626
+ * Items without a repository are grouped under "(unknown)".
627
+ */
628
+ export function groupDashboardItemsByRepo(items) {
629
+ const groups = {};
630
+ for (const item of items) {
631
+ const key = item.repository || "(unknown)";
632
+ if (!groups[key])
633
+ groups[key] = [];
634
+ groups[key].push(item);
635
+ }
636
+ return groups;
637
+ }
621
638
  /**
622
639
  * Render dashboard data as an ASCII bar chart.
623
640
  */
@@ -249,6 +249,18 @@ export async function resolveRepoFromProject(client) {
249
249
  }
250
250
  return inferred.repo;
251
251
  }
252
+ // When a registry is loaded, use its first repo entry as the default
253
+ const registry = client.config.repoRegistry;
254
+ if (registry) {
255
+ const firstRepoName = Object.keys(registry.repos)[0];
256
+ const firstRepoEntry = registry.repos[firstRepoName];
257
+ client.config.repo = firstRepoName;
258
+ if (!client.config.owner && firstRepoEntry.owner) {
259
+ client.config.owner = firstRepoEntry.owner;
260
+ }
261
+ console.error(`[ralph-hero] Multiple repos linked. Using "${firstRepoName}" as default (from .ralph-repos.yml).`);
262
+ return firstRepoName;
263
+ }
252
264
  const repoList = result.repos.map(r => r.nameWithOwner).join(", ");
253
265
  console.error(`[ralph-hero] Multiple repos linked to project: ${repoList}. ` +
254
266
  `Set RALPH_GH_REPO to select the default repo. ` +
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Registry loader — fetches .ralph-repos.yml from project-linked GitHub repos.
3
+ *
4
+ * Called once at MCP server startup (before resolveRepoFromProject).
5
+ * The registry is optional: if not found in any linked repo, returns null.
6
+ */
7
+ import { parseRepoRegistry } from "./repo-registry.js";
8
+ import { queryProjectRepositories } from "./helpers.js";
9
+ import { resolveProjectOwner } from "../types.js";
10
+ const REGISTRY_FILENAME = ".ralph-repos.yml";
11
+ // ---------------------------------------------------------------------------
12
+ // Internal: fetch file content from a single repo via GraphQL
13
+ // ---------------------------------------------------------------------------
14
+ async function fetchFileFromRepo(client, owner, repo, expression) {
15
+ try {
16
+ const result = await client.query(`query($owner: String!, $repo: String!, $expr: String!) {
17
+ repository(owner: $owner, name: $repo) {
18
+ object(expression: $expr) {
19
+ ... on Blob { __typename text }
20
+ }
21
+ }
22
+ }`, { owner, repo, expr: expression });
23
+ const obj = result.repository?.object;
24
+ if (obj?.__typename === "Blob" && typeof obj.text === "string") {
25
+ return obj.text;
26
+ }
27
+ return null;
28
+ }
29
+ catch {
30
+ return null;
31
+ }
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // Public: load registry from project-linked repos
35
+ // ---------------------------------------------------------------------------
36
+ /**
37
+ * Load .ralph-repos.yml from a GitHub repo linked to the configured project.
38
+ *
39
+ * Strategy:
40
+ * 1. If client.config.repo is set, try that repo first.
41
+ * 2. Otherwise, query linked repos and try each in order until one has the file.
42
+ * 3. If none found or any validation error, log a warning and return null.
43
+ *
44
+ * The registry is optional — consumers must handle null gracefully.
45
+ */
46
+ export async function loadRepoRegistry(client) {
47
+ const expression = `HEAD:${REGISTRY_FILENAME}`;
48
+ // Try the explicitly configured repo first
49
+ if (client.config.repo && client.config.owner) {
50
+ const text = await fetchFileFromRepo(client, client.config.owner, client.config.repo, expression);
51
+ if (text !== null) {
52
+ return parseAndLog(text, client.config.owner, client.config.repo);
53
+ }
54
+ }
55
+ // Fall back to querying linked repos (multi-repo case or no explicit repo)
56
+ const projectOwner = resolveProjectOwner(client.config);
57
+ const projectNumber = client.config.projectNumber;
58
+ if (!projectOwner || !projectNumber) {
59
+ // Not enough config to query project repos — skip silently
60
+ return null;
61
+ }
62
+ const projectRepos = await queryProjectRepositories(client, projectOwner, projectNumber);
63
+ if (!projectRepos || projectRepos.repos.length === 0) {
64
+ return null;
65
+ }
66
+ for (const linked of projectRepos.repos) {
67
+ // Skip if we already tried this repo above
68
+ if (linked.repo === client.config.repo &&
69
+ linked.owner === client.config.owner) {
70
+ continue;
71
+ }
72
+ const text = await fetchFileFromRepo(client, linked.owner, linked.repo, expression);
73
+ if (text !== null) {
74
+ return parseAndLog(text, linked.owner, linked.repo);
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Internal: parse and log on success or validation failure
81
+ // ---------------------------------------------------------------------------
82
+ function parseAndLog(text, owner, repo) {
83
+ try {
84
+ const registry = parseRepoRegistry(text);
85
+ const repoCount = Object.keys(registry.repos).length;
86
+ const patternCount = registry.patterns
87
+ ? Object.keys(registry.patterns).length
88
+ : 0;
89
+ console.error(`[ralph-hero] Repo registry loaded from ${owner}/${repo}: ` +
90
+ `${repoCount} repo${repoCount !== 1 ? "s" : ""}, ` +
91
+ `${patternCount} pattern${patternCount !== 1 ? "s" : ""}`);
92
+ return registry;
93
+ }
94
+ catch (err) {
95
+ console.error(`[ralph-hero] Warning: ${REGISTRY_FILENAME} found in ${owner}/${repo} ` +
96
+ `but failed to parse: ${err instanceof Error ? err.message : String(err)}`);
97
+ return null;
98
+ }
99
+ }
100
+ //# sourceMappingURL=registry-loader.js.map
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Repo registry schema, types, parser, and lookup helpers.
3
+ *
4
+ * Provides structured configuration for multi-repo portfolio management.
5
+ * The registry YAML file maps short repo names to metadata (domain, tech
6
+ * stack, default labels/assignees/estimate) and defines cross-repo
7
+ * decomposition patterns for feature work.
8
+ *
9
+ * Consumers:
10
+ * - server startup: loads registry once from env-specified path
11
+ * - create_issue tool: merges repo defaults into issue creation args
12
+ * - decompose_feature tool: looks up patterns for cross-repo decomposition
13
+ * - pipeline_dashboard: groups issues by repo domain
14
+ */
15
+ import { parse as yamlParse } from "yaml";
16
+ import { z } from "zod";
17
+ // ---------------------------------------------------------------------------
18
+ // Schemas
19
+ // ---------------------------------------------------------------------------
20
+ /**
21
+ * Default values applied to issues created in a specific repo.
22
+ * All fields are optional — only specified ones are used.
23
+ *
24
+ * Example YAML:
25
+ * defaults:
26
+ * labels: ["backend", "infra"]
27
+ * assignees: ["cdubiel08"]
28
+ * estimate: "S"
29
+ */
30
+ export const RepoDefaultsSchema = z.object({
31
+ labels: z
32
+ .array(z.string())
33
+ .optional()
34
+ .describe("Default labels to apply to issues in this repo"),
35
+ assignees: z
36
+ .array(z.string())
37
+ .optional()
38
+ .describe("Default assignees for issues in this repo"),
39
+ estimate: z
40
+ .string()
41
+ .optional()
42
+ .describe("Default estimate (e.g., 'XS', 'S', 'M', 'L') for this repo"),
43
+ });
44
+ /**
45
+ * A single repository entry in the registry.
46
+ *
47
+ * Example YAML:
48
+ * mcp-server:
49
+ * owner: cdubiel08
50
+ * domain: platform
51
+ * tech: [typescript, node]
52
+ * defaults:
53
+ * labels: [backend]
54
+ * paths: [plugin/ralph-hero/mcp-server]
55
+ */
56
+ export const RepoEntrySchema = z.object({
57
+ owner: z
58
+ .string()
59
+ .optional()
60
+ .describe("GitHub owner (user or org); falls back to RALPH_GH_OWNER if omitted"),
61
+ domain: z
62
+ .string()
63
+ .describe("Functional domain this repo belongs to (e.g., 'platform', 'frontend')"),
64
+ tech: z
65
+ .array(z.string())
66
+ .optional()
67
+ .describe("Technology stack tags for this repo (e.g., ['typescript', 'react'])"),
68
+ defaults: RepoDefaultsSchema
69
+ .optional()
70
+ .describe("Default values applied to issues created in this repo"),
71
+ paths: z
72
+ .array(z.string())
73
+ .optional()
74
+ .describe("Monorepo sub-paths owned by this repo (e.g., ['packages/core'])"),
75
+ });
76
+ /**
77
+ * One step in a cross-repo decomposition pattern.
78
+ *
79
+ * Example YAML:
80
+ * - repo: mcp-server
81
+ * role: Implement MCP tool endpoint
82
+ */
83
+ export const DecompositionStepSchema = z.object({
84
+ repo: z
85
+ .string()
86
+ .describe("Registry key of the repo responsible for this step"),
87
+ role: z
88
+ .string()
89
+ .describe("Human-readable description of what this repo does in the decomposition"),
90
+ });
91
+ /**
92
+ * A named cross-repo decomposition pattern for feature work.
93
+ *
94
+ * Example YAML:
95
+ * full-stack-feature:
96
+ * description: "Frontend + backend + infra change"
97
+ * decomposition:
98
+ * - repo: frontend
99
+ * role: Build UI
100
+ * - repo: mcp-server
101
+ * role: Add API endpoint
102
+ * dependency-flow:
103
+ * - mcp-server -> frontend
104
+ */
105
+ export const PatternSchema = z.object({
106
+ description: z
107
+ .string()
108
+ .describe("Human-readable description of when to use this pattern"),
109
+ decomposition: z
110
+ .array(DecompositionStepSchema)
111
+ .min(1)
112
+ .describe("Ordered list of repo steps for this pattern (at least one required)"),
113
+ "dependency-flow": z
114
+ .array(z.string())
115
+ .optional()
116
+ .describe("Dependency edges between repos (e.g., 'api -> frontend')"),
117
+ });
118
+ /**
119
+ * Top-level repo registry configuration.
120
+ *
121
+ * Example YAML:
122
+ * version: 1
123
+ * repos:
124
+ * mcp-server:
125
+ * domain: platform
126
+ * tech: [typescript]
127
+ * patterns:
128
+ * full-stack:
129
+ * description: "Full stack feature"
130
+ * decomposition:
131
+ * - repo: mcp-server
132
+ * role: Backend
133
+ */
134
+ export const RepoRegistrySchema = z.object({
135
+ version: z
136
+ .literal(1)
137
+ .describe("Schema version for forward compatibility"),
138
+ repos: z
139
+ .record(z.string(), RepoEntrySchema)
140
+ .refine((r) => Object.keys(r).length >= 1, {
141
+ message: "At least one repo entry is required",
142
+ })
143
+ .describe("Map of short repo name to repo metadata"),
144
+ patterns: z
145
+ .record(z.string(), PatternSchema)
146
+ .optional()
147
+ .describe("Named cross-repo decomposition patterns"),
148
+ });
149
+ // ---------------------------------------------------------------------------
150
+ // Parser
151
+ // ---------------------------------------------------------------------------
152
+ /**
153
+ * Parse a YAML string into a validated RepoRegistry.
154
+ *
155
+ * Throws a descriptive error if:
156
+ * - the YAML is syntactically invalid
157
+ * - the parsed value does not match RepoRegistrySchema
158
+ *
159
+ * @param yamlContent - Raw YAML string (e.g., from fs.readFileSync)
160
+ * @returns Validated RepoRegistry object
161
+ */
162
+ export function parseRepoRegistry(yamlContent) {
163
+ let parsed;
164
+ try {
165
+ parsed = yamlParse(yamlContent);
166
+ }
167
+ catch (err) {
168
+ throw new Error(`Repo registry YAML parse error: ${err instanceof Error ? err.message : String(err)}`);
169
+ }
170
+ const result = RepoRegistrySchema.safeParse(parsed);
171
+ if (!result.success) {
172
+ const messages = result.error.issues
173
+ .map((issue) => {
174
+ const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
175
+ return ` ${path}: ${issue.message}`;
176
+ })
177
+ .join("\n");
178
+ throw new Error(`Repo registry schema validation failed:\n${messages}`);
179
+ }
180
+ return result.data;
181
+ }
182
+ // ---------------------------------------------------------------------------
183
+ // Lookup Helpers
184
+ // ---------------------------------------------------------------------------
185
+ /**
186
+ * Look up a repo entry by name (case-insensitive).
187
+ *
188
+ * @param registry - Parsed registry
189
+ * @param repoName - Repo key to look up (case-insensitive)
190
+ * @returns { name, entry } if found, undefined otherwise
191
+ */
192
+ export function lookupRepo(registry, repoName) {
193
+ const lower = repoName.toLowerCase();
194
+ for (const [name, entry] of Object.entries(registry.repos)) {
195
+ if (name.toLowerCase() === lower) {
196
+ return { name, entry };
197
+ }
198
+ }
199
+ return undefined;
200
+ }
201
+ /**
202
+ * Look up a decomposition pattern by name (case-insensitive).
203
+ *
204
+ * @param registry - Parsed registry
205
+ * @param patternName - Pattern key to look up (case-insensitive)
206
+ * @returns { name, pattern } if found, undefined otherwise
207
+ */
208
+ export function lookupPattern(registry, patternName) {
209
+ if (!registry.patterns)
210
+ return undefined;
211
+ const lower = patternName.toLowerCase();
212
+ for (const [name, pattern] of Object.entries(registry.patterns)) {
213
+ if (name.toLowerCase() === lower) {
214
+ return { name, pattern };
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Defaults Merging
221
+ // ---------------------------------------------------------------------------
222
+ /**
223
+ * Merge repo defaults with caller-supplied args.
224
+ *
225
+ * Merge rules:
226
+ * - **labels**: additive union, deduplicated (args labels + defaults labels)
227
+ * - **assignees**: args win; fall back to defaults if args omit it
228
+ * - **estimate**: args win; fall back to defaults if args omit it
229
+ *
230
+ * @param defaults - Repo-level defaults from registry (may be undefined)
231
+ * @param args - Caller-supplied values (take precedence for non-label fields)
232
+ * @returns Merged object with only the fields that have values
233
+ */
234
+ export function mergeDefaults(defaults, args) {
235
+ const result = {};
236
+ // Labels: additive union, deduplicated
237
+ const allLabels = [
238
+ ...(args.labels ?? []),
239
+ ...(defaults?.labels ?? []),
240
+ ];
241
+ if (allLabels.length > 0) {
242
+ result.labels = [...new Set(allLabels)];
243
+ }
244
+ // Assignees: args win, fall back to defaults
245
+ const assignees = args.assignees ?? defaults?.assignees;
246
+ if (assignees !== undefined) {
247
+ result.assignees = assignees;
248
+ }
249
+ // Estimate: args win, fall back to defaults
250
+ const estimate = args.estimate ?? defaults?.estimate;
251
+ if (estimate !== undefined) {
252
+ result.estimate = estimate;
253
+ }
254
+ return result;
255
+ }
256
+ //# sourceMappingURL=repo-registry.js.map
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { z } from "zod";
9
9
  import { paginateConnection } from "../lib/pagination.js";
10
- import { buildDashboard, formatMarkdown, formatAscii, DEFAULT_HEALTH_CONFIG, } from "../lib/dashboard.js";
10
+ import { buildDashboard, formatMarkdown, formatAscii, groupDashboardItemsByRepo, DEFAULT_HEALTH_CONFIG, } from "../lib/dashboard.js";
11
11
  import { toolSuccess, toolError, resolveProjectOwner, resolveProjectNumbers } from "../types.js";
12
12
  import { detectWorkStreams } from "../lib/work-stream-detection.js";
13
13
  import { detectStreamPipelinePositions } from "../lib/pipeline-detection.js";
@@ -225,6 +225,10 @@ export function registerDashboardTools(server, client, fieldCache) {
225
225
  }))
226
226
  .optional()
227
227
  .describe("Pre-computed stream assignments from detect_work_streams. When provided, dashboard includes a Streams section."),
228
+ groupBy: z
229
+ .enum(["repo"])
230
+ .optional()
231
+ .describe("Group dashboard output by dimension. 'repo' groups items by repository within the project."),
228
232
  }, async (args) => {
229
233
  try {
230
234
  const owner = args.owner || resolveProjectOwner(client.config);
@@ -287,6 +291,34 @@ export function registerDashboardTools(server, client, fieldCache) {
287
291
  for (const phase of dashboard.phases) {
288
292
  phase.issues = phase.issues.slice(0, issuesPerPhase);
289
293
  }
294
+ // If groupBy=repo, build per-repo sub-dashboards
295
+ if (args.groupBy === "repo") {
296
+ const repoGroups = groupDashboardItemsByRepo(allItems);
297
+ if (args.format === "markdown") {
298
+ let md = "# Pipeline Dashboard (by repo)\n\n";
299
+ for (const [repoName, repoItems] of Object.entries(repoGroups)) {
300
+ const sub = buildDashboard(repoItems, healthConfig);
301
+ md += `## ${repoName} (${repoItems.length} items)\n\n`;
302
+ md += formatMarkdown(sub) + "\n\n";
303
+ }
304
+ return toolSuccess({ markdown: md });
305
+ }
306
+ if (args.format === "ascii") {
307
+ let ascii = "Pipeline Dashboard (by repo)\n\n";
308
+ for (const [repoName, repoItems] of Object.entries(repoGroups)) {
309
+ const sub = buildDashboard(repoItems, healthConfig);
310
+ ascii += `=== ${repoName} (${repoItems.length} items) ===\n`;
311
+ ascii += formatAscii(sub) + "\n\n";
312
+ }
313
+ return toolSuccess({ ascii });
314
+ }
315
+ // JSON format
316
+ const repoResults = {};
317
+ for (const [repoName, repoItems] of Object.entries(repoGroups)) {
318
+ repoResults[repoName] = buildDashboard(repoItems, healthConfig);
319
+ }
320
+ return toolSuccess({ groupBy: "repo", repos: repoResults });
321
+ }
290
322
  // Compute metrics if requested
291
323
  let metrics;
292
324
  if (args.includeMetrics) {
@@ -0,0 +1,321 @@
1
+ /**
2
+ * MCP tools for cross-repo feature decomposition.
3
+ *
4
+ * Uses the .ralph-repos.yml registry to split a feature description into
5
+ * repo-specific issues following a named decomposition pattern.
6
+ *
7
+ * When dryRun=true (default), returns a proposed issue list for review.
8
+ * When dryRun=false, creates the actual issues on GitHub and adds each to
9
+ * the project board.
10
+ */
11
+ import { z } from "zod";
12
+ import { toolSuccess, toolError } from "../types.js";
13
+ import { ensureFieldCache, resolveFullConfigOptionalRepo, } from "../lib/helpers.js";
14
+ import { lookupRepo, lookupPattern, mergeDefaults, } from "../lib/repo-registry.js";
15
+ // ---------------------------------------------------------------------------
16
+ // Pure function: buildDecomposition
17
+ // ---------------------------------------------------------------------------
18
+ /**
19
+ * Build a decomposition proposal from a feature description and registry pattern.
20
+ *
21
+ * Pure function — no side effects, no API calls. Suitable for testing.
22
+ *
23
+ * @param input - { title, description, pattern }
24
+ * @param registry - Validated repo registry
25
+ * @param defaultOwner - Global owner fallback (from client.config.owner)
26
+ * @returns DecompositionResult with proposed issues and dependency chain
27
+ * @throws Error if pattern is not found (lists available patterns)
28
+ * @throws Error if a step references a repo not in the registry
29
+ */
30
+ export function buildDecomposition(input, registry, defaultOwner) {
31
+ // Look up the pattern (case-insensitive)
32
+ const patternLookup = lookupPattern(registry, input.pattern);
33
+ if (!patternLookup) {
34
+ const available = Object.keys(registry.patterns ?? {});
35
+ const availableList = available.length > 0
36
+ ? `Available patterns: ${available.join(", ")}`
37
+ : "No patterns are defined in the registry.";
38
+ throw new Error(`Pattern "${input.pattern}" not found in registry. ${availableList}`);
39
+ }
40
+ const { name: matchedPattern, pattern } = patternLookup;
41
+ // Build a proposed issue for each step
42
+ const proposed_issues = pattern.decomposition.map((step) => {
43
+ const repoLookup = lookupRepo(registry, step.repo);
44
+ if (!repoLookup) {
45
+ const available = Object.keys(registry.repos);
46
+ throw new Error(`Pattern step references unknown repo "${step.repo}". ` +
47
+ `Available repos: ${available.join(", ")}`);
48
+ }
49
+ const { name: repoKey, entry } = repoLookup;
50
+ // Merge defaults: no caller overrides at decompose time
51
+ const merged = mergeDefaults(entry.defaults, {});
52
+ // Resolve owner: registry entry > global default
53
+ const owner = entry.owner ?? defaultOwner;
54
+ // Generate title
55
+ const title = `[${input.title}] ${step.role}`;
56
+ // Generate body
57
+ const domainLine = `Domain: ${entry.domain}`;
58
+ const techLine = entry.tech && entry.tech.length > 0
59
+ ? `Tech: ${entry.tech.join(", ")}`
60
+ : undefined;
61
+ const pathsLine = entry.paths && entry.paths.length > 0
62
+ ? `Paths: ${entry.paths.join(", ")}`
63
+ : undefined;
64
+ const repoDomainParts = [domainLine, techLine, pathsLine].filter((p) => p !== undefined);
65
+ const body = [
66
+ `## Context`,
67
+ ``,
68
+ input.description,
69
+ ``,
70
+ `## Scope`,
71
+ ``,
72
+ step.role,
73
+ ``,
74
+ `## Repo`,
75
+ ``,
76
+ repoDomainParts.join("\n"),
77
+ ].join("\n");
78
+ const proposed = { repoKey, owner, title, body };
79
+ if (merged.labels !== undefined)
80
+ proposed.labels = merged.labels;
81
+ if (merged.assignees !== undefined)
82
+ proposed.assignees = merged.assignees;
83
+ if (merged.estimate !== undefined)
84
+ proposed.estimate = merged.estimate;
85
+ return proposed;
86
+ });
87
+ // Extract dependency chain
88
+ const dependency_chain = pattern["dependency-flow"] ?? [];
89
+ return { proposed_issues, dependency_chain, matched_pattern: matchedPattern };
90
+ }
91
+ // ---------------------------------------------------------------------------
92
+ // Register decompose tools
93
+ // ---------------------------------------------------------------------------
94
+ export function registerDecomposeTools(server, client, fieldCache) {
95
+ // -------------------------------------------------------------------------
96
+ // ralph_hero__decompose_feature
97
+ // -------------------------------------------------------------------------
98
+ server.tool("ralph_hero__decompose_feature", "Split a feature description into repo-specific issues using a named decomposition pattern from .ralph-repos.yml. " +
99
+ "When no pattern is specified, lists available patterns and repos. " +
100
+ "When dryRun=true (default), returns a proposal without creating anything. " +
101
+ "When dryRun=false, creates the issues on GitHub and adds each to the project board. " +
102
+ "Returns: proposed_issues (with title, body, labels, assignees, estimate per repo), dependency_chain, matched_pattern.", {
103
+ title: z.string().describe("Feature name or title (used as issue title prefix)"),
104
+ description: z
105
+ .string()
106
+ .describe("Feature description (included in each issue body as Context)"),
107
+ pattern: z
108
+ .string()
109
+ .optional()
110
+ .describe("Decomposition pattern name from .ralph-repos.yml. " +
111
+ "Omit to list available patterns and repos."),
112
+ dryRun: z
113
+ .boolean()
114
+ .optional()
115
+ .default(true)
116
+ .describe("When true (default), return the proposal without creating issues. " +
117
+ "When false, create real issues on GitHub and add them to the project."),
118
+ projectNumber: z.coerce
119
+ .number()
120
+ .optional()
121
+ .describe("Project number override (defaults to configured project)"),
122
+ }, async (args) => {
123
+ try {
124
+ const registry = client.config.repoRegistry;
125
+ // If no registry is loaded, return a helpful error
126
+ if (!registry) {
127
+ return toolError("No .ralph-repos.yml registry loaded. " +
128
+ "Create a .ralph-repos.yml file in your repo root and restart the MCP server. " +
129
+ "See the ralph-hero documentation for the schema.");
130
+ }
131
+ // If no pattern specified, list available patterns and repos
132
+ if (!args.pattern) {
133
+ const available_patterns = Object.entries(registry.patterns ?? {}).map(([name, p]) => ({
134
+ name,
135
+ description: p.description,
136
+ steps: p.decomposition.map((s) => `${s.repo}: ${s.role}`),
137
+ }));
138
+ const available_repos = Object.entries(registry.repos).map(([name, r]) => ({
139
+ name,
140
+ domain: r.domain,
141
+ tech: r.tech,
142
+ owner: r.owner,
143
+ }));
144
+ return toolSuccess({
145
+ message: "No pattern specified. Provide a `pattern` parameter to decompose the feature.",
146
+ available_patterns,
147
+ available_repos,
148
+ });
149
+ }
150
+ // Build decomposition (pure — throws on unknown pattern or missing repo)
151
+ let decomposition;
152
+ try {
153
+ decomposition = buildDecomposition({
154
+ title: args.title,
155
+ description: args.description,
156
+ pattern: args.pattern,
157
+ }, registry, client.config.owner);
158
+ }
159
+ catch (err) {
160
+ const message = err instanceof Error ? err.message : String(err);
161
+ return toolError(message);
162
+ }
163
+ // dryRun=true: return proposal without creating anything
164
+ if (args.dryRun !== false) {
165
+ return toolSuccess({
166
+ dryRun: true,
167
+ matched_pattern: decomposition.matched_pattern,
168
+ proposed_issues: decomposition.proposed_issues,
169
+ dependency_chain: decomposition.dependency_chain,
170
+ });
171
+ }
172
+ // dryRun=false: create real issues on GitHub
173
+ const { projectNumber, projectOwner } = resolveFullConfigOptionalRepo(client, { projectNumber: args.projectNumber });
174
+ // Ensure field cache is populated for project operations
175
+ await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
176
+ const projectId = fieldCache.getProjectId(projectNumber);
177
+ if (!projectId) {
178
+ return toolError("Could not resolve project ID for adding issues to project");
179
+ }
180
+ const createdIssues = [];
181
+ // Create each proposed issue
182
+ for (const proposed of decomposition.proposed_issues) {
183
+ const issueOwner = proposed.owner ?? client.config.owner;
184
+ if (!issueOwner) {
185
+ return toolError(`Cannot create issue for repo "${proposed.repoKey}": no owner resolved. ` +
186
+ `Set owner in the registry entry or configure RALPH_GH_OWNER.`);
187
+ }
188
+ // Step 1: Get repository ID
189
+ const repoResult = await client.query(`query($owner: String!, $repo: String!) {
190
+ repository(owner: $owner, name: $repo) { id }
191
+ }`, { owner: issueOwner, repo: proposed.repoKey }, { cache: true, cacheTtlMs: 60 * 60 * 1000 });
192
+ const repoId = repoResult.repository?.id;
193
+ if (!repoId) {
194
+ return toolError(`Repository ${issueOwner}/${proposed.repoKey} not found. ` +
195
+ `Check that the repo key in .ralph-repos.yml matches the GitHub repo name.`);
196
+ }
197
+ // Step 2: Resolve label IDs if provided
198
+ let labelIds;
199
+ if (proposed.labels && proposed.labels.length > 0) {
200
+ const labelResult = await client.query(`query($owner: String!, $repo: String!) {
201
+ repository(owner: $owner, name: $repo) {
202
+ labels(first: 100) {
203
+ nodes { id name }
204
+ }
205
+ }
206
+ }`, { owner: issueOwner, repo: proposed.repoKey }, { cache: true, cacheTtlMs: 5 * 60 * 1000 });
207
+ const allLabels = labelResult.repository.labels.nodes;
208
+ labelIds = proposed.labels
209
+ .map((name) => allLabels.find((l) => l.name === name)?.id)
210
+ .filter((id) => id !== undefined);
211
+ }
212
+ // Step 3: Create the issue
213
+ const createResult = await client.mutate(`mutation($repoId: ID!, $title: String!, $body: String, $labelIds: [ID!]) {
214
+ createIssue(input: {
215
+ repositoryId: $repoId,
216
+ title: $title,
217
+ body: $body,
218
+ labelIds: $labelIds
219
+ }) {
220
+ issue {
221
+ id
222
+ number
223
+ title
224
+ url
225
+ }
226
+ }
227
+ }`, {
228
+ repoId,
229
+ title: proposed.title,
230
+ body: proposed.body,
231
+ labelIds: labelIds ?? null,
232
+ });
233
+ const issue = createResult.createIssue.issue;
234
+ // Cache the node ID
235
+ client
236
+ .getCache()
237
+ .set(`issue-node-id:${issueOwner}/${proposed.repoKey}#${issue.number}`, issue.id, 30 * 60 * 1000);
238
+ // Step 4: Add to project
239
+ const addResult = await client.projectMutate(`mutation($projectId: ID!, $contentId: ID!) {
240
+ addProjectV2ItemById(input: {
241
+ projectId: $projectId,
242
+ contentId: $contentId
243
+ }) {
244
+ item { id }
245
+ }
246
+ }`, { projectId, contentId: issue.id });
247
+ const projectItemId = addResult.addProjectV2ItemById.item.id;
248
+ // Cache project item ID
249
+ client
250
+ .getCache()
251
+ .set(`project-item-id:${issueOwner}/${proposed.repoKey}#${issue.number}`, projectItemId, 30 * 60 * 1000);
252
+ createdIssues.push({
253
+ repoKey: proposed.repoKey,
254
+ owner: issueOwner,
255
+ number: issue.number,
256
+ id: issue.id,
257
+ title: issue.title,
258
+ url: issue.url,
259
+ projectItemId,
260
+ });
261
+ }
262
+ // Step 5: Wire dependencies (addSubIssue for dependency edges)
263
+ // Parse "a -> b" edges from dependency_chain; cross-repo sub-issues
264
+ // may not be supported — catch and continue.
265
+ const wiringResults = [];
266
+ for (const edge of decomposition.dependency_chain) {
267
+ const match = edge.match(/^\s*(\S+)\s*->\s*(\S+)\s*$/);
268
+ if (!match) {
269
+ wiringResults.push({
270
+ edge,
271
+ status: "skipped",
272
+ reason: "Unrecognized edge format (expected 'a -> b')",
273
+ });
274
+ continue;
275
+ }
276
+ const [, fromRepo, toRepo] = match;
277
+ const fromIssue = createdIssues.find((i) => i.repoKey === fromRepo);
278
+ const toIssue = createdIssues.find((i) => i.repoKey === toRepo);
279
+ if (!fromIssue || !toIssue) {
280
+ wiringResults.push({
281
+ edge,
282
+ status: "skipped",
283
+ reason: `Could not find created issue for repo "${fromRepo}" or "${toRepo}"`,
284
+ });
285
+ continue;
286
+ }
287
+ try {
288
+ await client.mutate(`mutation($parentId: ID!, $childId: ID!) {
289
+ addSubIssue(input: {
290
+ issueId: $parentId,
291
+ subIssueId: $childId
292
+ }) {
293
+ issue { id }
294
+ subIssue { id }
295
+ }
296
+ }`, { parentId: fromIssue.id, childId: toIssue.id });
297
+ wiringResults.push({ edge, status: "ok" });
298
+ }
299
+ catch (err) {
300
+ const reason = err instanceof Error ? err.message : String(err);
301
+ wiringResults.push({
302
+ edge,
303
+ status: "skipped",
304
+ reason: `addSubIssue failed (cross-repo sub-issues may not be supported): ${reason}`,
305
+ });
306
+ }
307
+ }
308
+ return toolSuccess({
309
+ dryRun: false,
310
+ matched_pattern: decomposition.matched_pattern,
311
+ created_issues: createdIssues,
312
+ dependency_wiring: wiringResults,
313
+ });
314
+ }
315
+ catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ return toolError(`Failed to decompose feature: ${message}`);
318
+ }
319
+ });
320
+ }
321
+ //# sourceMappingURL=decompose-tools.js.map
@@ -15,6 +15,7 @@ import { parseDateMath } from "../lib/date-math.js";
15
15
  import { expandProfile } from "../lib/filter-profiles.js";
16
16
  import { toolSuccess, toolError } from "../types.js";
17
17
  import { ensureFieldCache, resolveIssueNodeId, resolveProjectItemId, updateProjectItemField, resolveConfig, resolveFullConfig, resolveFullConfigOptionalRepo, syncStatusField, autoAdvanceParent, } from "../lib/helpers.js";
18
+ import { lookupRepo, mergeDefaults } from "../lib/repo-registry.js";
18
19
  // ---------------------------------------------------------------------------
19
20
  // Register issue tools
20
21
  // ---------------------------------------------------------------------------
@@ -630,7 +631,33 @@ export function registerIssueTools(server, client, fieldCache) {
630
631
  priority: z.string().optional().describe("Priority (P0, P1, P2, P3)"),
631
632
  }, async (args) => {
632
633
  try {
633
- const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, args);
634
+ // Resolve owner from registry for repo shorthand
635
+ let resolvedArgs = { ...args };
636
+ const registry = client.config.repoRegistry;
637
+ if (registry && args.repo && !args.owner) {
638
+ const repoLookup = lookupRepo(registry, args.repo);
639
+ if (repoLookup?.entry.owner) {
640
+ resolvedArgs = { ...args, owner: repoLookup.entry.owner };
641
+ }
642
+ }
643
+ const { owner, repo, projectNumber, projectOwner } = resolveFullConfig(client, resolvedArgs);
644
+ // Apply registry defaults if available
645
+ let effectiveLabels = args.labels;
646
+ let effectiveAssignees = args.assignees;
647
+ let effectiveEstimate = args.estimate;
648
+ if (registry) {
649
+ const repoLookup = lookupRepo(registry, repo);
650
+ if (repoLookup) {
651
+ const merged = mergeDefaults(repoLookup.entry.defaults, {
652
+ labels: effectiveLabels,
653
+ assignees: effectiveAssignees,
654
+ estimate: effectiveEstimate,
655
+ });
656
+ effectiveLabels = merged.labels;
657
+ effectiveAssignees = merged.assignees;
658
+ effectiveEstimate = merged.estimate;
659
+ }
660
+ }
634
661
  // Ensure field cache is populated
635
662
  await ensureFieldCache(client, fieldCache, projectOwner, projectNumber);
636
663
  // Step 1: Get repository ID
@@ -643,7 +670,7 @@ export function registerIssueTools(server, client, fieldCache) {
643
670
  }
644
671
  // Step 2: Resolve label IDs if provided
645
672
  let labelIds;
646
- if (args.labels && args.labels.length > 0) {
673
+ if (effectiveLabels && effectiveLabels.length > 0) {
647
674
  const labelResult = await client.query(`query($owner: String!, $repo: String!) {
648
675
  repository(owner: $owner, name: $repo) {
649
676
  labels(first: 100) {
@@ -652,7 +679,7 @@ export function registerIssueTools(server, client, fieldCache) {
652
679
  }
653
680
  }`, { owner, repo }, { cache: true, cacheTtlMs: 5 * 60 * 1000 });
654
681
  const allLabels = labelResult.repository.labels.nodes;
655
- labelIds = args.labels
682
+ labelIds = effectiveLabels
656
683
  .map((name) => allLabels.find((l) => l.name === name)?.id)
657
684
  .filter((id) => id !== undefined);
658
685
  }
@@ -707,8 +734,8 @@ export function registerIssueTools(server, client, fieldCache) {
707
734
  await updateProjectItemField(client, fieldCache, projectItemId, "Workflow State", args.workflowState, projectNumber);
708
735
  await syncStatusField(client, fieldCache, projectItemId, args.workflowState, projectNumber);
709
736
  }
710
- if (args.estimate) {
711
- await updateProjectItemField(client, fieldCache, projectItemId, "Estimate", args.estimate, projectNumber);
737
+ if (effectiveEstimate) {
738
+ await updateProjectItemField(client, fieldCache, projectItemId, "Estimate", effectiveEstimate, projectNumber);
712
739
  }
713
740
  if (args.priority) {
714
741
  await updateProjectItemField(client, fieldCache, projectItemId, "Priority", args.priority, projectNumber);
@@ -721,7 +748,7 @@ export function registerIssueTools(server, client, fieldCache) {
721
748
  projectItemId,
722
749
  fieldsSet: {
723
750
  workflowState: args.workflowState || null,
724
- estimate: args.estimate || null,
751
+ estimate: effectiveEstimate || null,
725
752
  priority: args.priority || null,
726
753
  },
727
754
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ralph-hero-mcp-server",
3
- "version": "2.5.2",
3
+ "version": "2.5.3",
4
4
  "description": "MCP server for GitHub Projects V2 - Ralph workflow automation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",