cyrus-edge-worker 0.2.21 → 0.2.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/ActivityPoster.d.ts +15 -0
  2. package/dist/ActivityPoster.d.ts.map +1 -0
  3. package/dist/ActivityPoster.js +194 -0
  4. package/dist/ActivityPoster.js.map +1 -0
  5. package/dist/AgentSessionManager.d.ts +63 -21
  6. package/dist/AgentSessionManager.d.ts.map +1 -1
  7. package/dist/AgentSessionManager.js +413 -404
  8. package/dist/AgentSessionManager.js.map +1 -1
  9. package/dist/AskUserQuestionHandler.d.ts +3 -2
  10. package/dist/AskUserQuestionHandler.d.ts.map +1 -1
  11. package/dist/AskUserQuestionHandler.js +15 -12
  12. package/dist/AskUserQuestionHandler.js.map +1 -1
  13. package/dist/AttachmentService.d.ts +69 -0
  14. package/dist/AttachmentService.d.ts.map +1 -0
  15. package/dist/AttachmentService.js +369 -0
  16. package/dist/AttachmentService.js.map +1 -0
  17. package/dist/ChatSessionHandler.d.ts +87 -0
  18. package/dist/ChatSessionHandler.d.ts.map +1 -0
  19. package/dist/ChatSessionHandler.js +227 -0
  20. package/dist/ChatSessionHandler.js.map +1 -0
  21. package/dist/ConfigManager.d.ts +91 -0
  22. package/dist/ConfigManager.d.ts.map +1 -0
  23. package/dist/ConfigManager.js +229 -0
  24. package/dist/ConfigManager.js.map +1 -0
  25. package/dist/EdgeWorker.d.ts +152 -120
  26. package/dist/EdgeWorker.d.ts.map +1 -1
  27. package/dist/EdgeWorker.js +1335 -2125
  28. package/dist/EdgeWorker.js.map +1 -1
  29. package/dist/GitService.d.ts +14 -10
  30. package/dist/GitService.d.ts.map +1 -1
  31. package/dist/GitService.js +91 -12
  32. package/dist/GitService.js.map +1 -1
  33. package/dist/GlobalSessionRegistry.d.ts +142 -0
  34. package/dist/GlobalSessionRegistry.d.ts.map +1 -0
  35. package/dist/GlobalSessionRegistry.js +254 -0
  36. package/dist/GlobalSessionRegistry.js.map +1 -0
  37. package/dist/PromptBuilder.d.ts +182 -0
  38. package/dist/PromptBuilder.d.ts.map +1 -0
  39. package/dist/PromptBuilder.js +963 -0
  40. package/dist/PromptBuilder.js.map +1 -0
  41. package/dist/RepositoryRouter.d.ts +3 -2
  42. package/dist/RepositoryRouter.d.ts.map +1 -1
  43. package/dist/RepositoryRouter.js +39 -37
  44. package/dist/RepositoryRouter.js.map +1 -1
  45. package/dist/RunnerSelectionService.d.ts +71 -0
  46. package/dist/RunnerSelectionService.d.ts.map +1 -0
  47. package/dist/RunnerSelectionService.js +392 -0
  48. package/dist/RunnerSelectionService.js.map +1 -0
  49. package/dist/SharedApplicationServer.d.ts +3 -1
  50. package/dist/SharedApplicationServer.d.ts.map +1 -1
  51. package/dist/SharedApplicationServer.js +21 -17
  52. package/dist/SharedApplicationServer.js.map +1 -1
  53. package/dist/SharedWebhookServer.d.ts +3 -1
  54. package/dist/SharedWebhookServer.d.ts.map +1 -1
  55. package/dist/SharedWebhookServer.js +19 -16
  56. package/dist/SharedWebhookServer.js.map +1 -1
  57. package/dist/SlackChatAdapter.d.ts +25 -0
  58. package/dist/SlackChatAdapter.d.ts.map +1 -0
  59. package/dist/SlackChatAdapter.js +163 -0
  60. package/dist/SlackChatAdapter.js.map +1 -0
  61. package/dist/WorktreeIncludeService.d.ts +2 -9
  62. package/dist/WorktreeIncludeService.d.ts.map +1 -1
  63. package/dist/WorktreeIncludeService.js +3 -9
  64. package/dist/WorktreeIncludeService.js.map +1 -1
  65. package/dist/index.d.ts +7 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4 -0
  68. package/dist/index.js.map +1 -1
  69. package/dist/label-prompt-template.md +6 -2
  70. package/dist/procedures/ProcedureAnalyzer.d.ts +6 -4
  71. package/dist/procedures/ProcedureAnalyzer.d.ts.map +1 -1
  72. package/dist/procedures/ProcedureAnalyzer.js +32 -12
  73. package/dist/procedures/ProcedureAnalyzer.js.map +1 -1
  74. package/dist/procedures/registry.d.ts +19 -19
  75. package/dist/procedures/registry.d.ts.map +1 -1
  76. package/dist/procedures/registry.js +19 -19
  77. package/dist/procedures/registry.js.map +1 -1
  78. package/dist/procedures/types.d.ts +1 -0
  79. package/dist/procedures/types.d.ts.map +1 -1
  80. package/dist/prompt-assembly/types.d.ts +2 -0
  81. package/dist/prompt-assembly/types.d.ts.map +1 -1
  82. package/dist/prompts/graphite-orchestrator.md +4 -2
  83. package/dist/prompts/orchestrator.md +5 -3
  84. package/dist/prompts/standard-issue-assigned-user-prompt.md +7 -0
  85. package/dist/prompts/subroutines/gh-pr.md +12 -0
  86. package/dist/sinks/IActivitySink.d.ts +60 -0
  87. package/dist/sinks/IActivitySink.d.ts.map +1 -0
  88. package/dist/sinks/IActivitySink.js +2 -0
  89. package/dist/sinks/IActivitySink.js.map +1 -0
  90. package/dist/sinks/LinearActivitySink.d.ts +69 -0
  91. package/dist/sinks/LinearActivitySink.d.ts.map +1 -0
  92. package/dist/sinks/LinearActivitySink.js +111 -0
  93. package/dist/sinks/LinearActivitySink.js.map +1 -0
  94. package/dist/sinks/NoopActivitySink.d.ts +13 -0
  95. package/dist/sinks/NoopActivitySink.d.ts.map +1 -0
  96. package/dist/sinks/NoopActivitySink.js +17 -0
  97. package/dist/sinks/NoopActivitySink.js.map +1 -0
  98. package/dist/sinks/index.d.ts +9 -0
  99. package/dist/sinks/index.d.ts.map +1 -0
  100. package/dist/sinks/index.js +8 -0
  101. package/dist/sinks/index.js.map +1 -0
  102. package/label-prompt-template.md +6 -2
  103. package/package.json +17 -11
  104. package/prompts/graphite-orchestrator.md +4 -2
  105. package/prompts/orchestrator.md +5 -3
  106. package/prompts/standard-issue-assigned-user-prompt.md +7 -0
@@ -0,0 +1,963 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ /**
5
+ * Responsible for building various prompt types used in the EdgeWorker.
6
+ *
7
+ * Extracted from EdgeWorker to improve separation of concerns.
8
+ * Handles label-based prompts, mention prompts, issue context prompts,
9
+ * issue update prompts, subroutine prompt loading, and related utilities.
10
+ */
11
+ export class PromptBuilder {
12
+ logger;
13
+ repositories;
14
+ issueTrackers;
15
+ gitService;
16
+ config;
17
+ constructor(deps) {
18
+ this.logger = deps.logger;
19
+ this.repositories = deps.repositories;
20
+ this.issueTrackers = deps.issueTrackers;
21
+ this.gitService = deps.gitService;
22
+ this.config = deps.config;
23
+ }
24
+ // ========================================================================
25
+ // PROMPT BUILDING METHODS
26
+ // ========================================================================
27
+ /**
28
+ * Determine system prompt based on issue labels and repository configuration
29
+ */
30
+ async determineSystemPromptFromLabels(labels, repository) {
31
+ if (labels.length === 0) {
32
+ return undefined;
33
+ }
34
+ // Lowercase labels for case-insensitive comparison
35
+ const lowercaseLabels = labels.map((label) => label.toLowerCase());
36
+ // HARDCODED RULE: Always check for 'orchestrator' label (case-insensitive)
37
+ // regardless of whether repository.labelPrompts is configured.
38
+ // This matches the hardcoded routing behavior from CYPACK-715.
39
+ const hasHardcodedOrchestratorLabel = lowercaseLabels.includes("orchestrator");
40
+ // If no labelPrompts configured but has hardcoded orchestrator label,
41
+ // load orchestrator system prompt directly
42
+ if (!repository.labelPrompts && hasHardcodedOrchestratorLabel) {
43
+ try {
44
+ const __filename = fileURLToPath(import.meta.url);
45
+ const __dirname = dirname(__filename);
46
+ const promptPath = join(__dirname, "..", "prompts", "orchestrator.md");
47
+ const promptContent = await readFile(promptPath, "utf-8");
48
+ this.logger.debug(`Using orchestrator system prompt (hardcoded rule) for labels: ${labels.join(", ")}`);
49
+ const promptVersion = this.extractVersionTag(promptContent);
50
+ if (promptVersion) {
51
+ this.logger.debug(`orchestrator system prompt version: ${promptVersion}`);
52
+ }
53
+ return {
54
+ prompt: promptContent,
55
+ version: promptVersion,
56
+ type: "orchestrator",
57
+ };
58
+ }
59
+ catch (error) {
60
+ this.logger.error(`Failed to load orchestrator prompt template:`, error);
61
+ return undefined;
62
+ }
63
+ }
64
+ // If no labelPrompts configured and no hardcoded orchestrator, return undefined
65
+ if (!repository.labelPrompts) {
66
+ return undefined;
67
+ }
68
+ // Check for graphite-orchestrator first (requires BOTH graphite AND orchestrator labels)
69
+ const graphiteConfig = repository.labelPrompts.graphite;
70
+ const graphiteLabels = Array.isArray(graphiteConfig)
71
+ ? graphiteConfig
72
+ : (graphiteConfig?.labels ?? ["graphite"]);
73
+ const hasGraphiteLabel = graphiteLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
74
+ const orchestratorConfig = repository.labelPrompts.orchestrator;
75
+ const orchestratorLabels = Array.isArray(orchestratorConfig)
76
+ ? orchestratorConfig
77
+ : (orchestratorConfig?.labels ?? ["orchestrator"]);
78
+ // Use hardcoded check OR config-based check for orchestrator
79
+ const hasOrchestratorLabel = hasHardcodedOrchestratorLabel ||
80
+ orchestratorLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
81
+ // If both graphite AND orchestrator labels are present, use graphite-orchestrator prompt
82
+ if (hasGraphiteLabel && hasOrchestratorLabel) {
83
+ try {
84
+ const __filename = fileURLToPath(import.meta.url);
85
+ const __dirname = dirname(__filename);
86
+ const promptPath = join(__dirname, "..", "prompts", "graphite-orchestrator.md");
87
+ const promptContent = await readFile(promptPath, "utf-8");
88
+ this.logger.debug(`Using graphite-orchestrator system prompt for labels: ${labels.join(", ")}`);
89
+ const promptVersion = this.extractVersionTag(promptContent);
90
+ if (promptVersion) {
91
+ this.logger.debug(`graphite-orchestrator system prompt version: ${promptVersion}`);
92
+ }
93
+ return {
94
+ prompt: promptContent,
95
+ version: promptVersion,
96
+ type: "graphite-orchestrator",
97
+ };
98
+ }
99
+ catch (error) {
100
+ this.logger.error(`Failed to load graphite-orchestrator prompt template:`, error);
101
+ // Fall through to regular orchestrator if graphite-orchestrator prompt fails
102
+ }
103
+ }
104
+ // Check each prompt type for matching labels
105
+ const promptTypes = [
106
+ "debugger",
107
+ "builder",
108
+ "scoper",
109
+ "orchestrator",
110
+ ];
111
+ for (const promptType of promptTypes) {
112
+ const promptConfig = repository.labelPrompts[promptType];
113
+ // Handle both old array format and new object format for backward compatibility
114
+ const configuredLabels = Array.isArray(promptConfig)
115
+ ? promptConfig
116
+ : promptConfig?.labels;
117
+ // For orchestrator type, also check the hardcoded 'orchestrator' label
118
+ // This ensures orchestrator prompt loads even without explicit labelPrompts config
119
+ const matchesLabel = promptType === "orchestrator"
120
+ ? hasHardcodedOrchestratorLabel ||
121
+ configuredLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()))
122
+ : configuredLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
123
+ if (matchesLabel) {
124
+ try {
125
+ // Load the prompt template from file
126
+ const __filename = fileURLToPath(import.meta.url);
127
+ const __dirname = dirname(__filename);
128
+ const promptPath = join(__dirname, "..", "prompts", `${promptType}.md`);
129
+ const promptContent = await readFile(promptPath, "utf-8");
130
+ this.logger.debug(`Using ${promptType} system prompt for labels: ${labels.join(", ")}`);
131
+ // Extract and log version tag if present
132
+ const promptVersion = this.extractVersionTag(promptContent);
133
+ if (promptVersion) {
134
+ this.logger.debug(`${promptType} system prompt version: ${promptVersion}`);
135
+ }
136
+ return {
137
+ prompt: promptContent,
138
+ version: promptVersion,
139
+ type: promptType,
140
+ };
141
+ }
142
+ catch (error) {
143
+ this.logger.error(`Failed to load ${promptType} prompt template:`, error);
144
+ return undefined;
145
+ }
146
+ }
147
+ }
148
+ return undefined;
149
+ }
150
+ /**
151
+ * Build simplified prompt for label-based workflows
152
+ * @param issue Full Linear issue
153
+ * @param repository Repository configuration
154
+ * @param attachmentManifest Optional attachment manifest
155
+ * @param guidance Optional agent guidance rules from Linear
156
+ * @returns Formatted prompt string
157
+ */
158
+ async buildLabelBasedPrompt(issue, repository, attachmentManifest = "", guidance) {
159
+ this.logger.debug(`buildLabelBasedPrompt called for issue ${issue.identifier}`);
160
+ try {
161
+ // Load the label-based prompt template
162
+ const __filename = fileURLToPath(import.meta.url);
163
+ const __dirname = dirname(__filename);
164
+ const templatePath = resolve(__dirname, "../label-prompt-template.md");
165
+ this.logger.debug(`Loading label prompt template from: ${templatePath}`);
166
+ const template = await readFile(templatePath, "utf-8");
167
+ this.logger.debug(`Template loaded, length: ${template.length} characters`);
168
+ // Extract and log version tag if present
169
+ const templateVersion = this.extractVersionTag(template);
170
+ if (templateVersion) {
171
+ this.logger.debug(`Label prompt template version: ${templateVersion}`);
172
+ }
173
+ // Determine the base branch considering parent issues
174
+ const baseBranch = await this.determineBaseBranch(issue, repository);
175
+ // Fetch assignee information (including Linear profile URL, GitHub user ID, and noreply email)
176
+ let assigneeId = "";
177
+ let assigneeName = "";
178
+ let assigneeLinearProfileUrl = "";
179
+ let assigneeGitHubUsername = "";
180
+ let assigneeGitHubUserId = "";
181
+ let assigneeGitHubNoreplyEmail = "";
182
+ try {
183
+ if (issue.assigneeId) {
184
+ assigneeId = issue.assigneeId;
185
+ // Fetch the full assignee object to get the name, profile URL, and GitHub user ID
186
+ const assignee = await issue.assignee;
187
+ if (assignee) {
188
+ assigneeName = assignee.displayName || assignee.name || "";
189
+ assigneeLinearProfileUrl = assignee.url || "";
190
+ // Resolve GitHub username from gitHubUserId
191
+ if (assignee.gitHubUserId) {
192
+ assigneeGitHubUserId = assignee.gitHubUserId;
193
+ const ghUsername = await this.resolveGitHubUsername(assignee.gitHubUserId);
194
+ if (ghUsername) {
195
+ assigneeGitHubUsername = ghUsername;
196
+ assigneeGitHubNoreplyEmail = `${assignee.gitHubUserId}+${ghUsername}@users.noreply.github.com`;
197
+ }
198
+ }
199
+ }
200
+ }
201
+ }
202
+ catch (error) {
203
+ this.logger.warn(`Failed to fetch assignee details:`, error);
204
+ }
205
+ // Get IssueTrackerService for this repository
206
+ const issueTracker = this.issueTrackers.get(repository.id);
207
+ if (!issueTracker) {
208
+ this.logger.error(`No IssueTrackerService found for repository ${repository.id}`);
209
+ throw new Error(`No IssueTrackerService found for repository ${repository.id}`);
210
+ }
211
+ // Fetch workspace teams and labels
212
+ let workspaceTeams = "";
213
+ let workspaceLabels = "";
214
+ try {
215
+ this.logger.debug(`Fetching workspace teams and labels for repository ${repository.id}`);
216
+ // Fetch teams
217
+ const teamsConnection = await issueTracker.fetchTeams();
218
+ const teamsArray = [];
219
+ for (const team of teamsConnection.nodes) {
220
+ teamsArray.push({
221
+ id: team.id,
222
+ name: team.name,
223
+ key: team.key,
224
+ description: team.description || "",
225
+ color: team.color,
226
+ });
227
+ }
228
+ workspaceTeams = teamsArray
229
+ .map((team) => `- ${team.name} (${team.key}): ${team.id}${team.description ? ` - ${team.description}` : ""}`)
230
+ .join("\n");
231
+ // Fetch labels
232
+ const labelsConnection = await issueTracker.fetchLabels();
233
+ const labelsArray = [];
234
+ for (const label of labelsConnection.nodes) {
235
+ labelsArray.push({
236
+ id: label.id,
237
+ name: label.name,
238
+ description: label.description || "",
239
+ color: label.color,
240
+ });
241
+ }
242
+ workspaceLabels = labelsArray
243
+ .map((label) => `- ${label.name}: ${label.id}${label.description ? ` - ${label.description}` : ""}`)
244
+ .join("\n");
245
+ this.logger.debug(`Fetched ${teamsArray.length} teams and ${labelsArray.length} labels`);
246
+ }
247
+ catch (error) {
248
+ this.logger.warn(`Failed to fetch workspace teams and labels:`, error);
249
+ }
250
+ // Generate routing context for orchestrator mode
251
+ const routingContext = this.generateRoutingContext(repository);
252
+ // Build the simplified prompt with only essential variables
253
+ let prompt = template
254
+ .replace(/{{repository_name}}/g, repository.name)
255
+ .replace(/{{base_branch}}/g, baseBranch)
256
+ .replace(/{{issue_id}}/g, issue.id || "")
257
+ .replace(/{{issue_identifier}}/g, issue.identifier || "")
258
+ .replace(/{{issue_title}}/g, issue.title || "")
259
+ .replace(/{{issue_description}}/g, issue.description || "No description provided")
260
+ .replace(/{{issue_url}}/g, issue.url || "")
261
+ .replace(/{{assignee_id}}/g, assigneeId)
262
+ .replace(/{{assignee_name}}/g, assigneeName)
263
+ .replace(/{{assignee_linear_profile_url}}/g, assigneeLinearProfileUrl)
264
+ .replace(/{{assignee_github_username}}/g, assigneeGitHubUsername)
265
+ .replace(/{{assignee_github_user_id}}/g, assigneeGitHubUserId)
266
+ .replace(/{{assignee_github_noreply_email}}/g, assigneeGitHubNoreplyEmail)
267
+ .replace(/{{workspace_teams}}/g, workspaceTeams)
268
+ .replace(/{{workspace_labels}}/g, workspaceLabels)
269
+ // Replace routing context - if empty, also remove the preceding newlines
270
+ .replace(routingContext ? /{{routing_context}}/g : /\n*{{routing_context}}/g, routingContext);
271
+ // Append agent guidance if present
272
+ prompt += this.formatAgentGuidance(guidance);
273
+ if (attachmentManifest) {
274
+ this.logger.debug(`Adding attachment manifest to label-based prompt, length: ${attachmentManifest.length} characters`);
275
+ prompt = `${prompt}\n\n${attachmentManifest}`;
276
+ }
277
+ this.logger.debug(`Label-based prompt built successfully, length: ${prompt.length} characters`);
278
+ return { prompt, version: templateVersion };
279
+ }
280
+ catch (error) {
281
+ this.logger.error(`Error building label-based prompt:`, error);
282
+ throw error;
283
+ }
284
+ }
285
+ /**
286
+ * Generate routing context for orchestrator mode
287
+ *
288
+ * This provides the orchestrator with information about available repositories
289
+ * and how to route sub-issues to them. The context includes:
290
+ * - List of configured repositories in the workspace
291
+ * - Routing rules for each repository (labels, teams, projects)
292
+ * - Instructions on using description tags for explicit routing
293
+ *
294
+ * @param currentRepository The repository handling the current orchestrator issue
295
+ * @returns XML-formatted routing context string, or empty string if no routing info available
296
+ */
297
+ generateRoutingContext(currentRepository) {
298
+ // Get all repositories in the same workspace
299
+ const workspaceRepos = Array.from(this.repositories.values()).filter((repo) => repo.linearWorkspaceId === currentRepository.linearWorkspaceId &&
300
+ repo.isActive !== false);
301
+ // If there's only one repository, no routing context needed
302
+ if (workspaceRepos.length <= 1) {
303
+ return "";
304
+ }
305
+ const repoDescriptions = workspaceRepos.map((repo) => {
306
+ const routingMethods = [];
307
+ // Description tag routing (always available)
308
+ const repoIdentifier = repo.githubUrl
309
+ ? repo.githubUrl.replace("https://github.com/", "")
310
+ : repo.name;
311
+ routingMethods.push(` - Description tag: Add \`[repo=${repoIdentifier}]\` to sub-issue description`);
312
+ // Label-based routing
313
+ if (repo.routingLabels && repo.routingLabels.length > 0) {
314
+ routingMethods.push(` - Routing labels: ${repo.routingLabels.map((l) => `"${l}"`).join(", ")}`);
315
+ }
316
+ // Team-based routing
317
+ if (repo.teamKeys && repo.teamKeys.length > 0) {
318
+ routingMethods.push(` - Team keys: ${repo.teamKeys.map((t) => `"${t}"`).join(", ")} (create issue in this team)`);
319
+ }
320
+ // Project-based routing
321
+ if (repo.projectKeys && repo.projectKeys.length > 0) {
322
+ routingMethods.push(` - Project keys: ${repo.projectKeys.map((p) => `"${p}"`).join(", ")} (add issue to this project)`);
323
+ }
324
+ const currentMarker = repo.id === currentRepository.id ? " (current)" : "";
325
+ return ` <repository name="${repo.name}"${currentMarker}>
326
+ <github_url>${repo.githubUrl || "N/A"}</github_url>
327
+ <routing_methods>
328
+ ${routingMethods.join("\n")}
329
+ </routing_methods>
330
+ </repository>`;
331
+ });
332
+ return `<repository_routing_context>
333
+ <description>
334
+ When creating sub-issues that should be handled in a DIFFERENT repository, use one of these routing methods.
335
+
336
+ **IMPORTANT - Routing Priority Order:**
337
+ The system evaluates routing methods in this strict priority order. The FIRST match wins:
338
+
339
+ 1. **Description Tag (Priority 1 - Highest, Recommended)**: Add \`[repo=org/repo-name]\` or \`[repo=repo-name]\` to the sub-issue description. This is the most explicit and reliable method.
340
+ 2. **Routing Labels (Priority 2)**: Apply a label configured to route to the target repository.
341
+ 3. **Project Assignment (Priority 3)**: Add the issue to a project that routes to the target repository.
342
+ 4. **Team Selection (Priority 4 - Lowest)**: Create the issue in a Linear team that routes to the target repository.
343
+
344
+ For reliable cross-repository routing, prefer Description Tags as they are explicit and unambiguous.
345
+ </description>
346
+
347
+ <available_repositories>
348
+ ${repoDescriptions.join("\n")}
349
+ </available_repositories>
350
+ </repository_routing_context>`;
351
+ }
352
+ /**
353
+ * Build prompt for mention-triggered sessions
354
+ * @param issue Full Linear issue object
355
+ * @param agentSession The agent session containing the mention
356
+ * @param attachmentManifest Optional attachment manifest to append
357
+ * @param guidance Optional agent guidance rules from Linear
358
+ * @returns The constructed prompt and optional version tag
359
+ */
360
+ async buildMentionPrompt(issue, agentSession, attachmentManifest = "", guidance) {
361
+ try {
362
+ this.logger.debug(`Building mention prompt for issue ${issue.identifier}`);
363
+ // Get the mention comment metadata
364
+ const mentionContent = agentSession.comment?.body || "";
365
+ const authorName = agentSession.creator?.name || agentSession.creator?.id || "Unknown";
366
+ const timestamp = agentSession.createdAt || new Date().toISOString();
367
+ // Build a focused prompt with comment metadata
368
+ let prompt = `You were mentioned in a Linear comment on this issue:
369
+
370
+ <linear_issue>
371
+ <id>${issue.id}</id>
372
+ <identifier>${issue.identifier}</identifier>
373
+ <title>${issue.title}</title>
374
+ <url>${issue.url}</url>
375
+ </linear_issue>
376
+
377
+ <mention_comment>
378
+ <author>${authorName}</author>
379
+ <timestamp>${timestamp}</timestamp>
380
+ <content>
381
+ ${mentionContent}
382
+ </content>
383
+ </mention_comment>
384
+
385
+ Focus on addressing the specific request in the mention. You can use the Linear MCP tools to fetch additional context if needed.`;
386
+ // Append agent guidance if present
387
+ prompt += this.formatAgentGuidance(guidance);
388
+ // Append attachment manifest if any
389
+ if (attachmentManifest) {
390
+ prompt = `${prompt}\n\n${attachmentManifest}`;
391
+ }
392
+ return { prompt };
393
+ }
394
+ catch (error) {
395
+ this.logger.error(`Error building mention prompt:`, error);
396
+ throw error;
397
+ }
398
+ }
399
+ /**
400
+ * Build a prompt for Claude using the improved XML-style template
401
+ * @param issue Full Linear issue
402
+ * @param repository Repository configuration
403
+ * @param newComment Optional new comment to focus on (for handleNewRootComment)
404
+ * @param attachmentManifest Optional attachment manifest
405
+ * @param guidance Optional agent guidance rules from Linear
406
+ * @returns Formatted prompt string
407
+ */
408
+ async buildIssueContextPrompt(issue, repository, newComment, attachmentManifest = "", guidance) {
409
+ this.logger.debug(`buildIssueContextPrompt called for issue ${issue.identifier}${newComment ? " with new comment" : ""}`);
410
+ try {
411
+ // Use custom template if provided (repository-specific)
412
+ let templatePath = repository.promptTemplatePath;
413
+ // If no custom template, use the standard issue assigned user prompt template
414
+ if (!templatePath) {
415
+ const __filename = fileURLToPath(import.meta.url);
416
+ const __dirname = dirname(__filename);
417
+ templatePath = resolve(__dirname, "../prompts/standard-issue-assigned-user-prompt.md");
418
+ }
419
+ // Load the template
420
+ this.logger.debug(`Loading prompt template from: ${templatePath}`);
421
+ const template = await readFile(templatePath, "utf-8");
422
+ this.logger.debug(`Template loaded, length: ${template.length} characters`);
423
+ // Extract and log version tag if present
424
+ const templateVersion = this.extractVersionTag(template);
425
+ if (templateVersion) {
426
+ this.logger.debug(`Prompt template version: ${templateVersion}`);
427
+ }
428
+ // Get state name from Linear API
429
+ const state = await issue.state;
430
+ const stateName = state?.name || "Unknown";
431
+ // Determine the base branch considering parent issues
432
+ const baseBranch = await this.determineBaseBranch(issue, repository);
433
+ // Get formatted comment threads
434
+ const issueTracker = this.issueTrackers.get(repository.id);
435
+ let commentThreads = "No comments yet.";
436
+ if (issueTracker && issue.id) {
437
+ try {
438
+ this.logger.debug(`Fetching comments for issue ${issue.identifier}`);
439
+ const comments = await issueTracker.fetchComments(issue.id);
440
+ const commentNodes = comments.nodes;
441
+ if (commentNodes.length > 0) {
442
+ commentThreads = await this.formatCommentThreads(commentNodes);
443
+ this.logger.debug(`Formatted ${commentNodes.length} comments into threads`);
444
+ }
445
+ }
446
+ catch (error) {
447
+ this.logger.error("Failed to fetch comments:", error);
448
+ }
449
+ }
450
+ // Fetch assignee information (including Linear profile URL, GitHub username, user ID, and noreply email)
451
+ let assigneeName = "";
452
+ let assigneeLinearProfileUrl = "";
453
+ let assigneeGitHubUsername = "";
454
+ let assigneeGitHubUserId = "";
455
+ let assigneeGitHubNoreplyEmail = "";
456
+ try {
457
+ if (issue.assigneeId) {
458
+ const assignee = await issue.assignee;
459
+ if (assignee) {
460
+ assigneeName = assignee.displayName || assignee.name || "";
461
+ assigneeLinearProfileUrl = assignee.url || "";
462
+ if (assignee.gitHubUserId) {
463
+ assigneeGitHubUserId = assignee.gitHubUserId;
464
+ const ghUsername = await this.resolveGitHubUsername(assignee.gitHubUserId);
465
+ if (ghUsername) {
466
+ assigneeGitHubUsername = ghUsername;
467
+ assigneeGitHubNoreplyEmail = `${assignee.gitHubUserId}+${ghUsername}@users.noreply.github.com`;
468
+ }
469
+ }
470
+ }
471
+ }
472
+ }
473
+ catch (error) {
474
+ this.logger.warn(`Failed to fetch assignee details:`, error);
475
+ }
476
+ // Build the prompt with all variables
477
+ let prompt = template
478
+ .replace(/{{repository_name}}/g, repository.name)
479
+ .replace(/{{issue_id}}/g, issue.id || "")
480
+ .replace(/{{issue_identifier}}/g, issue.identifier || "")
481
+ .replace(/{{issue_title}}/g, issue.title || "")
482
+ .replace(/{{issue_description}}/g, issue.description || "No description provided")
483
+ .replace(/{{issue_state}}/g, stateName)
484
+ .replace(/{{issue_priority}}/g, issue.priority?.toString() || "None")
485
+ .replace(/{{issue_url}}/g, issue.url || "")
486
+ .replace(/{{comment_threads}}/g, commentThreads)
487
+ .replace(/{{working_directory}}/g, this.config.handlers?.createWorkspace
488
+ ? "Will be created based on issue"
489
+ : repository.repositoryPath)
490
+ .replace(/{{base_branch}}/g, baseBranch)
491
+ .replace(/{{branch_name}}/g, this.gitService.sanitizeBranchName(issue.branchName))
492
+ .replace(/{{assignee_name}}/g, assigneeName)
493
+ .replace(/{{assignee_linear_profile_url}}/g, assigneeLinearProfileUrl)
494
+ .replace(/{{assignee_github_username}}/g, assigneeGitHubUsername)
495
+ .replace(/{{assignee_github_user_id}}/g, assigneeGitHubUserId)
496
+ .replace(/{{assignee_github_noreply_email}}/g, assigneeGitHubNoreplyEmail);
497
+ // Handle the optional new comment section
498
+ if (newComment) {
499
+ // Replace the conditional block
500
+ const newCommentSection = `<new_comment_to_address>
501
+ <author>{{new_comment_author}}</author>
502
+ <timestamp>{{new_comment_timestamp}}</timestamp>
503
+ <content>
504
+ {{new_comment_content}}
505
+ </content>
506
+ </new_comment_to_address>
507
+
508
+ IMPORTANT: Focus specifically on addressing the new comment above. This is a new request that requires your attention.`;
509
+ prompt = prompt.replace(/{{#if new_comment}}[\s\S]*?{{\/if}}/g, newCommentSection);
510
+ // Now replace the new comment variables
511
+ // We'll need to fetch the comment author
512
+ let authorName = "Unknown";
513
+ if (issueTracker) {
514
+ try {
515
+ const fullComment = await issueTracker.fetchComment(newComment.id);
516
+ const user = await fullComment.user;
517
+ authorName =
518
+ user?.displayName || user?.name || user?.email || "Unknown";
519
+ }
520
+ catch (error) {
521
+ this.logger.error("Failed to fetch comment author:", error);
522
+ }
523
+ }
524
+ prompt = prompt
525
+ .replace(/{{new_comment_author}}/g, authorName)
526
+ .replace(/{{new_comment_timestamp}}/g, new Date().toLocaleString())
527
+ .replace(/{{new_comment_content}}/g, newComment.body || "");
528
+ }
529
+ else {
530
+ // Remove the new comment section entirely (including preceding newlines)
531
+ prompt = prompt.replace(/\n*{{#if new_comment}}[\s\S]*?{{\/if}}/g, "");
532
+ }
533
+ // Append agent guidance if present
534
+ prompt += this.formatAgentGuidance(guidance);
535
+ // Append attachment manifest if provided
536
+ if (attachmentManifest) {
537
+ this.logger.debug(`Adding attachment manifest, length: ${attachmentManifest.length} characters`);
538
+ prompt = `${prompt}\n\n${attachmentManifest}`;
539
+ }
540
+ // Append repository-specific instruction if provided
541
+ if (repository.appendInstruction) {
542
+ this.logger.debug(`Adding repository-specific instruction`);
543
+ prompt = `${prompt}\n\n<repository-specific-instruction>\n${repository.appendInstruction}\n</repository-specific-instruction>`;
544
+ }
545
+ this.logger.debug(`Final prompt length: ${prompt.length} characters`);
546
+ return { prompt, version: templateVersion };
547
+ }
548
+ catch (error) {
549
+ this.logger.error("Failed to load prompt template:", error);
550
+ // Fallback to simple prompt
551
+ const state = await issue.state;
552
+ const stateName = state?.name || "Unknown";
553
+ // Determine the base branch considering parent issues
554
+ const baseBranch = await this.determineBaseBranch(issue, repository);
555
+ const fallbackPrompt = `Please help me with the following Linear issue:
556
+
557
+ Repository: ${repository.name}
558
+ Issue: ${issue.identifier}
559
+ Title: ${issue.title}
560
+ Description: ${issue.description || "No description provided"}
561
+ State: ${stateName}
562
+ Priority: ${issue.priority?.toString() || "None"}
563
+ Branch: ${issue.branchName}
564
+
565
+ Working directory: ${repository.repositoryPath}
566
+ Base branch: ${baseBranch}
567
+
568
+ ${newComment ? `New comment to address:\n${newComment.body}\n\n` : ""}Please analyze this issue and help implement a solution.`;
569
+ return { prompt: fallbackPrompt, version: undefined };
570
+ }
571
+ }
572
+ /**
573
+ * Build XML-formatted prompt for issue content updates (title/description/attachments)
574
+ *
575
+ * The prompt clearly shows what fields changed by comparing old vs new values,
576
+ * and includes guidance for the agent to evaluate whether these changes affect
577
+ * its current implementation or action plan.
578
+ */
579
+ buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom) {
580
+ const timestamp = new Date().toISOString();
581
+ const parts = [];
582
+ parts.push(`<issue_update>`);
583
+ parts.push(` <identifier>${issueIdentifier}</identifier>`);
584
+ parts.push(` <timestamp>${timestamp}</timestamp>`);
585
+ // Add title change if title was updated
586
+ if ("title" in updatedFrom) {
587
+ parts.push(` <title_change>`);
588
+ parts.push(` <old_title>${updatedFrom.title ?? ""}</old_title>`);
589
+ parts.push(` <new_title>${issueData.title}</new_title>`);
590
+ parts.push(` </title_change>`);
591
+ }
592
+ // Add description change if description was updated
593
+ if ("description" in updatedFrom) {
594
+ parts.push(` <description_change>`);
595
+ parts.push(` <old_description>${updatedFrom.description ?? ""}</old_description>`);
596
+ parts.push(` <new_description>${issueData.description ?? ""}</new_description>`);
597
+ parts.push(` </description_change>`);
598
+ }
599
+ // Add attachments change if attachments were updated
600
+ if ("attachments" in updatedFrom) {
601
+ parts.push(` <attachments_change>`);
602
+ parts.push(` <old_attachments>${JSON.stringify(updatedFrom.attachments ?? null)}</old_attachments>`);
603
+ parts.push(` <new_attachments>${JSON.stringify(issueData.attachments ?? null)}</new_attachments>`);
604
+ parts.push(` </attachments_change>`);
605
+ }
606
+ parts.push(`</issue_update>`);
607
+ // Add guidance for the agent on how to respond to this update
608
+ parts.push(``);
609
+ parts.push(`<guidance>`);
610
+ parts.push(` The issue has been updated while you are working on it. Please evaluate whether these changes`);
611
+ parts.push(` affect your current implementation or action plan. Consider the following:`);
612
+ parts.push(` - Does the updated content change the requirements or scope of your work?`);
613
+ parts.push(` - Are there new details, clarifications, or attachments that should inform your approach?`);
614
+ parts.push(` - Should you adjust your implementation strategy based on this update?`);
615
+ parts.push(` If the changes are relevant, incorporate them into your work. If not, you may continue as planned.`);
616
+ parts.push(`</guidance>`);
617
+ return parts.join("\n");
618
+ }
619
+ // ========================================================================
620
+ // COMMENT / GUIDANCE FORMATTING
621
+ // ========================================================================
622
+ /**
623
+ * Format Linear comments into a threaded structure that mirrors the Linear UI
624
+ * @param comments Array of Linear comments
625
+ * @returns Formatted string showing comment threads
626
+ */
627
+ async formatCommentThreads(comments) {
628
+ if (comments.length === 0) {
629
+ return "No comments yet.";
630
+ }
631
+ // Group comments by thread (root comments and their replies)
632
+ const threads = new Map();
633
+ const rootComments = [];
634
+ // First pass: identify root comments and create thread structure
635
+ for (const comment of comments) {
636
+ const parent = await comment.parent;
637
+ if (!parent) {
638
+ // This is a root comment
639
+ rootComments.push(comment);
640
+ threads.set(comment.id, { root: comment, replies: [] });
641
+ }
642
+ }
643
+ // Second pass: assign replies to their threads
644
+ for (const comment of comments) {
645
+ const parent = await comment.parent;
646
+ if (parent?.id) {
647
+ const thread = threads.get(parent.id);
648
+ if (thread) {
649
+ thread.replies.push(comment);
650
+ }
651
+ }
652
+ }
653
+ // Format threads in chronological order
654
+ const formattedThreads = [];
655
+ for (const rootComment of rootComments) {
656
+ const thread = threads.get(rootComment.id);
657
+ if (!thread)
658
+ continue;
659
+ // Format root comment
660
+ const rootUser = await rootComment.user;
661
+ const rootAuthor = rootUser?.displayName || rootUser?.name || rootUser?.email || "Unknown";
662
+ const rootTime = new Date(rootComment.createdAt).toLocaleString();
663
+ let threadText = `<comment_thread>
664
+ <root_comment>
665
+ <author>@${rootAuthor}</author>
666
+ <timestamp>${rootTime}</timestamp>
667
+ <content>
668
+ ${rootComment.body}
669
+ </content>
670
+ </root_comment>`;
671
+ // Format replies if any
672
+ if (thread.replies.length > 0) {
673
+ threadText += "\n <replies>";
674
+ for (const reply of thread.replies) {
675
+ const replyUser = await reply.user;
676
+ const replyAuthor = replyUser?.displayName ||
677
+ replyUser?.name ||
678
+ replyUser?.email ||
679
+ "Unknown";
680
+ const replyTime = new Date(reply.createdAt).toLocaleString();
681
+ threadText += `
682
+ <reply>
683
+ <author>@${replyAuthor}</author>
684
+ <timestamp>${replyTime}</timestamp>
685
+ <content>
686
+ ${reply.body}
687
+ </content>
688
+ </reply>`;
689
+ }
690
+ threadText += "\n </replies>";
691
+ }
692
+ threadText += "\n</comment_thread>";
693
+ formattedThreads.push(threadText);
694
+ }
695
+ return formattedThreads.join("\n\n");
696
+ }
697
+ /**
698
+ * Format agent guidance rules as markdown for injection into prompts
699
+ * @param guidance Array of guidance rules from Linear
700
+ * @returns Formatted markdown string with guidance, or empty string if no guidance
701
+ */
702
+ formatAgentGuidance(guidance) {
703
+ if (!guidance || guidance.length === 0) {
704
+ return "";
705
+ }
706
+ let formatted = "\n\n<agent_guidance>\nThe following guidance has been configured for this workspace/team in Linear. Team-specific guidance takes precedence over workspace-level guidance.\n";
707
+ for (const rule of guidance) {
708
+ let origin = "Global";
709
+ if (rule.origin) {
710
+ if (rule.origin.__typename === "TeamOriginWebhookPayload") {
711
+ origin = `Team (${rule.origin.team.displayName})`;
712
+ }
713
+ else {
714
+ origin = "Organization";
715
+ }
716
+ }
717
+ formatted += `\n## Guidance from ${origin}\n${rule.body}\n`;
718
+ }
719
+ formatted += "\n</agent_guidance>";
720
+ return formatted;
721
+ }
722
+ /**
723
+ * Extract version tag from template content
724
+ * @param templateContent The template content to parse
725
+ * @returns The version value if found, undefined otherwise
726
+ */
727
+ extractVersionTag(templateContent) {
728
+ // Match the version tag pattern: <version-tag value="..." />
729
+ const versionTagMatch = templateContent.match(/<version-tag\s+value="([^"]*)"\s*\/>/i);
730
+ const version = versionTagMatch ? versionTagMatch[1] : undefined;
731
+ // Return undefined for empty strings
732
+ return version?.trim() ? version : undefined;
733
+ }
734
+ /**
735
+ * Resolve a GitHub user ID (numeric string from Linear) to a GitHub username.
736
+ * Uses the public GitHub REST API: GET https://api.github.com/user/{id}
737
+ * @param gitHubUserId The numeric GitHub user ID from Linear's gitHubUserId field
738
+ * @returns The GitHub username (login), or undefined if resolution fails
739
+ */
740
+ async resolveGitHubUsername(gitHubUserId) {
741
+ try {
742
+ const response = await fetch(`https://api.github.com/user/${gitHubUserId}`, {
743
+ headers: {
744
+ Accept: "application/vnd.github.v3+json",
745
+ "User-Agent": "Cyrus-Agent",
746
+ },
747
+ });
748
+ if (!response.ok) {
749
+ this.logger.warn(`GitHub API returned ${response.status} for user ID ${gitHubUserId}`);
750
+ return undefined;
751
+ }
752
+ const data = (await response.json());
753
+ if (data.login) {
754
+ this.logger.debug(`Resolved GitHub user ID ${gitHubUserId} to username: ${data.login}`);
755
+ return data.login;
756
+ }
757
+ return undefined;
758
+ }
759
+ catch (error) {
760
+ this.logger.warn(`Failed to resolve GitHub username for user ID ${gitHubUserId}:`, error);
761
+ return undefined;
762
+ }
763
+ }
764
+ // ========================================================================
765
+ // SUBROUTINE / SHARED INSTRUCTION LOADING
766
+ // ========================================================================
767
+ /**
768
+ * Load a subroutine prompt file
769
+ * Extracted helper to make prompt assembly more readable
770
+ */
771
+ async loadSubroutinePrompt(subroutine, workspaceSlug) {
772
+ // Skip loading for "primary" - it's a placeholder that doesn't have a file
773
+ if (subroutine.promptPath === "primary") {
774
+ return null;
775
+ }
776
+ const __filename = fileURLToPath(import.meta.url);
777
+ const __dirname = dirname(__filename);
778
+ const subroutinePromptPath = join(__dirname, "prompts", subroutine.promptPath);
779
+ try {
780
+ let prompt = await readFile(subroutinePromptPath, "utf-8");
781
+ this.logger.debug(`Loaded ${subroutine.name} subroutine prompt (${prompt.length} characters)`);
782
+ // Perform template substitution if workspace slug is provided
783
+ if (workspaceSlug) {
784
+ prompt = prompt.replace(/https:\/\/linear\.app\/linear\/profiles\//g, `https://linear.app/${workspaceSlug}/profiles/`);
785
+ }
786
+ return prompt;
787
+ }
788
+ catch (error) {
789
+ this.logger.warn(`Failed to load subroutine prompt from ${subroutinePromptPath}:`, error);
790
+ return null;
791
+ }
792
+ }
793
+ /**
794
+ * Load shared instructions that get appended to all system prompts
795
+ */
796
+ async loadSharedInstructions() {
797
+ const __filename = fileURLToPath(import.meta.url);
798
+ const __dirname = dirname(__filename);
799
+ const instructionsPath = join(__dirname, "..", "prompts", "todolist-system-prompt-extension.md");
800
+ try {
801
+ const instructions = await readFile(instructionsPath, "utf-8");
802
+ return instructions;
803
+ }
804
+ catch (error) {
805
+ this.logger.error(`Failed to load shared instructions from ${instructionsPath}:`, error);
806
+ return ""; // Return empty string if file can't be loaded
807
+ }
808
+ }
809
+ // ========================================================================
810
+ // BRANCH / ISSUE UTILITIES
811
+ // ========================================================================
812
+ /**
813
+ * Determine the base branch for an issue, considering parent issues and blocked-by relationships
814
+ *
815
+ * Priority order:
816
+ * 1. If issue has graphite label AND has a "blocked by" relationship, use the blocking issue's branch
817
+ * (This enables Graphite stacking where each sub-issue branches off the previous)
818
+ * 2. If issue has a parent, use the parent's branch
819
+ * 3. Fall back to repository's default base branch
820
+ */
821
+ async determineBaseBranch(issue, repository) {
822
+ // Start with the repository's default base branch
823
+ let baseBranch = repository.baseBranch;
824
+ // Check if this issue has the graphite label - if so, blocked-by relationship takes priority
825
+ const isGraphiteIssue = await this.hasGraphiteLabel(issue, repository);
826
+ if (isGraphiteIssue) {
827
+ // For Graphite stacking: use the blocking issue's branch as base
828
+ const blockingIssues = await this.fetchBlockingIssues(issue);
829
+ if (blockingIssues.length > 0) {
830
+ // Use the first blocking issue's branch (typically there's only one in a stack)
831
+ const blockingIssue = blockingIssues[0];
832
+ this.logger.debug(`Issue ${issue.identifier} has graphite label and is blocked by ${blockingIssue.identifier}`);
833
+ // Get blocking issue's branch name
834
+ const blockingRawBranchName = blockingIssue.branchName ||
835
+ `${blockingIssue.identifier}-${(blockingIssue.title ?? "")
836
+ .toLowerCase()
837
+ .replace(/\s+/g, "-")
838
+ .substring(0, 30)}`;
839
+ const blockingBranchName = this.gitService.sanitizeBranchName(blockingRawBranchName);
840
+ // Check if blocking issue's branch exists
841
+ const blockingBranchExists = await this.gitService.branchExists(blockingBranchName, repository.repositoryPath);
842
+ if (blockingBranchExists) {
843
+ baseBranch = blockingBranchName;
844
+ this.logger.debug(`Using blocking issue branch '${blockingBranchName}' as base for Graphite-stacked issue ${issue.identifier}`);
845
+ return baseBranch;
846
+ }
847
+ this.logger.debug(`Blocking issue branch '${blockingBranchName}' not found, falling back to parent/default`);
848
+ }
849
+ }
850
+ // Check if issue has a parent (standard sub-issue behavior)
851
+ try {
852
+ const parent = await issue.parent;
853
+ if (parent) {
854
+ this.logger.debug(`Issue ${issue.identifier} has parent: ${parent.identifier}`);
855
+ // Get parent's branch name
856
+ const parentRawBranchName = parent.branchName ||
857
+ `${parent.identifier}-${parent.title
858
+ ?.toLowerCase()
859
+ .replace(/\s+/g, "-")
860
+ .substring(0, 30)}`;
861
+ const parentBranchName = this.gitService.sanitizeBranchName(parentRawBranchName);
862
+ // Check if parent branch exists
863
+ const parentBranchExists = await this.gitService.branchExists(parentBranchName, repository.repositoryPath);
864
+ if (parentBranchExists) {
865
+ baseBranch = parentBranchName;
866
+ this.logger.debug(`Using parent issue branch '${parentBranchName}' as base for sub-issue ${issue.identifier}`);
867
+ }
868
+ else {
869
+ this.logger.debug(`Parent branch '${parentBranchName}' not found, using default base branch '${repository.baseBranch}'`);
870
+ }
871
+ }
872
+ }
873
+ catch (_error) {
874
+ // Parent field might not exist or couldn't be fetched, use default base branch
875
+ this.logger.debug(`No parent issue found for ${issue.identifier}, using default base branch '${repository.baseBranch}'`);
876
+ }
877
+ return baseBranch;
878
+ }
879
+ /**
880
+ * Check if an issue has the graphite label
881
+ *
882
+ * @param issue The issue to check
883
+ * @param repository The repository configuration
884
+ * @returns True if the issue has the graphite label
885
+ */
886
+ async hasGraphiteLabel(issue, repository) {
887
+ const graphiteConfig = repository.labelPrompts?.graphite;
888
+ const graphiteLabels = Array.isArray(graphiteConfig)
889
+ ? graphiteConfig
890
+ : (graphiteConfig?.labels ?? ["graphite"]);
891
+ const issueLabels = await this.fetchIssueLabels(issue);
892
+ return graphiteLabels.some((label) => issueLabels.includes(label));
893
+ }
894
+ /**
895
+ * Fetch issues that block this issue (i.e., issues this one is "blocked by")
896
+ * Uses the inverseRelations field with type "blocks"
897
+ *
898
+ * Linear relations work like this:
899
+ * - When Issue A "blocks" Issue B, a relation is created with:
900
+ * - issue = A (the blocker)
901
+ * - relatedIssue = B (the blocked one)
902
+ * - type = "blocks"
903
+ *
904
+ * So to find "who blocks Issue B", we need inverseRelations (where B is the relatedIssue)
905
+ * and look for type === "blocks", then get the `issue` field (the blocker).
906
+ *
907
+ * @param issue The issue to fetch blocking issues for
908
+ * @returns Array of issues that block this one, or empty array if none
909
+ */
910
+ async fetchBlockingIssues(issue) {
911
+ try {
912
+ // inverseRelations contains relations where THIS issue is the relatedIssue
913
+ // When type is "blocks", it means the `issue` field blocks THIS issue
914
+ const inverseRelations = await issue.inverseRelations();
915
+ if (!inverseRelations?.nodes) {
916
+ return [];
917
+ }
918
+ const blockingIssues = [];
919
+ for (const relation of inverseRelations.nodes) {
920
+ // "blocks" type in inverseRelations means the `issue` blocks this one
921
+ if (relation.type === "blocks") {
922
+ // The `issue` field is the one that blocks THIS issue
923
+ const blockingIssue = await relation.issue;
924
+ if (blockingIssue) {
925
+ blockingIssues.push(blockingIssue);
926
+ }
927
+ }
928
+ }
929
+ this.logger.debug(`Issue ${issue.identifier} is blocked by ${blockingIssues.length} issue(s): ${blockingIssues.map((i) => i.identifier).join(", ") || "none"}`);
930
+ return blockingIssues;
931
+ }
932
+ catch (error) {
933
+ this.logger.error(`Failed to fetch blocking issues for ${issue.identifier}:`, error);
934
+ return [];
935
+ }
936
+ }
937
+ /**
938
+ * Convert full Linear SDK issue to CoreIssue interface for Session creation
939
+ */
940
+ convertLinearIssueToCore(issue) {
941
+ return {
942
+ id: issue.id,
943
+ identifier: issue.identifier,
944
+ title: issue.title || "",
945
+ description: issue.description || undefined,
946
+ branchName: issue.branchName, // Use the real branchName property!
947
+ };
948
+ }
949
+ /**
950
+ * Fetch issue labels for a given issue
951
+ */
952
+ async fetchIssueLabels(issue) {
953
+ try {
954
+ const labels = await issue.labels();
955
+ return labels.nodes.map((label) => label.name);
956
+ }
957
+ catch (error) {
958
+ this.logger.error(`Failed to fetch labels for issue ${issue.id}:`, error);
959
+ return [];
960
+ }
961
+ }
962
+ }
963
+ //# sourceMappingURL=PromptBuilder.js.map