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