better-symphony 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CLAUDE.md +60 -0
  2. package/LICENSE +21 -0
  3. package/README.md +292 -0
  4. package/dist/web/app.css +2 -0
  5. package/dist/web/index.html +13 -0
  6. package/dist/web/main.js +235 -0
  7. package/package.json +62 -0
  8. package/src/agent/claude-runner.ts +576 -0
  9. package/src/agent/protocol.ts +2 -0
  10. package/src/agent/runner.ts +2 -0
  11. package/src/agent/session.ts +113 -0
  12. package/src/cli.ts +354 -0
  13. package/src/config/loader.ts +379 -0
  14. package/src/config/types.ts +382 -0
  15. package/src/index.ts +53 -0
  16. package/src/linear-cli.ts +414 -0
  17. package/src/logging/logger.ts +143 -0
  18. package/src/orchestrator/multi-orchestrator.ts +266 -0
  19. package/src/orchestrator/orchestrator.ts +1357 -0
  20. package/src/orchestrator/scheduler.ts +195 -0
  21. package/src/orchestrator/state.ts +201 -0
  22. package/src/prompts/github-system-prompt.md +51 -0
  23. package/src/prompts/linear-system-prompt.md +44 -0
  24. package/src/tracker/client.ts +577 -0
  25. package/src/tracker/github-issues-tracker.ts +280 -0
  26. package/src/tracker/github-pr-tracker.ts +298 -0
  27. package/src/tracker/index.ts +9 -0
  28. package/src/tracker/interface.ts +76 -0
  29. package/src/tracker/linear-tracker.ts +147 -0
  30. package/src/tracker/queries.ts +281 -0
  31. package/src/tracker/types.ts +125 -0
  32. package/src/tui/App.tsx +157 -0
  33. package/src/tui/LogView.tsx +120 -0
  34. package/src/tui/StatusBar.tsx +72 -0
  35. package/src/tui/TabBar.tsx +55 -0
  36. package/src/tui/sink.ts +47 -0
  37. package/src/tui/types.ts +6 -0
  38. package/src/tui/useOrchestrator.ts +244 -0
  39. package/src/web/server.ts +182 -0
  40. package/src/web/sink.ts +67 -0
  41. package/src/web-ui/App.tsx +60 -0
  42. package/src/web-ui/components/agent-table.tsx +57 -0
  43. package/src/web-ui/components/header.tsx +72 -0
  44. package/src/web-ui/components/log-stream.tsx +111 -0
  45. package/src/web-ui/components/retry-table.tsx +58 -0
  46. package/src/web-ui/components/stats-cards.tsx +142 -0
  47. package/src/web-ui/components/ui/badge.tsx +30 -0
  48. package/src/web-ui/components/ui/button.tsx +39 -0
  49. package/src/web-ui/components/ui/card.tsx +32 -0
  50. package/src/web-ui/globals.css +27 -0
  51. package/src/web-ui/index.html +13 -0
  52. package/src/web-ui/lib/use-sse.ts +98 -0
  53. package/src/web-ui/lib/utils.ts +25 -0
  54. package/src/web-ui/main.tsx +4 -0
  55. package/src/workspace/hooks.ts +97 -0
  56. package/src/workspace/manager.ts +211 -0
  57. package/src/workspace/render-hook.ts +13 -0
  58. package/workflows/dev.md +127 -0
  59. package/workflows/github-issues.md +107 -0
  60. package/workflows/pr-review.md +89 -0
  61. package/workflows/prd.md +170 -0
  62. package/workflows/ralph.md +95 -0
  63. package/workflows/smoke.md +66 -0
@@ -0,0 +1,577 @@
1
+ /**
2
+ * Linear GraphQL Client for Symphony
3
+ * Implements Issue Tracker Client per Symphony Spec
4
+ */
5
+
6
+ import type { Issue, BlockerRef, ChildIssue, Comment } from "../config/types.js";
7
+ import { TrackerError } from "../config/types.js";
8
+ import type {
9
+ RateLimitState,
10
+ LinearIssueNode,
11
+ LinearIssuesData,
12
+ LinearIssueStatesData,
13
+ GraphQLResponse,
14
+ } from "./types.js";
15
+ import * as Q from "./queries.js";
16
+
17
+ const MAX_RETRIES = 3;
18
+ const NETWORK_TIMEOUT_MS = 30000;
19
+ const PAGE_SIZE = 50;
20
+
21
+ export class LinearClient {
22
+ private endpoint: string;
23
+ private apiKey: string;
24
+ private rateLimitState: RateLimitState = {
25
+ requestsLimit: 5000,
26
+ requestsRemaining: 5000,
27
+ requestsReset: 0,
28
+ complexityLimit: 250000,
29
+ complexityRemaining: 250000,
30
+ complexityReset: 0,
31
+ };
32
+
33
+ onRateLimit?: (attempt: number, waitSecs: number) => void;
34
+ onThrottle?: (remaining: number, limit: number) => void;
35
+
36
+ constructor(endpoint: string, apiKey: string) {
37
+ this.endpoint = endpoint;
38
+ this.apiKey = apiKey;
39
+ }
40
+
41
+ getRateLimitState(): RateLimitState {
42
+ return { ...this.rateLimitState };
43
+ }
44
+
45
+ private parseRateLimitHeaders(response: Response): void {
46
+ const requestsLimit = response.headers.get("X-RateLimit-Requests-Limit");
47
+ if (requestsLimit !== null) {
48
+ this.rateLimitState.requestsLimit = parseInt(requestsLimit, 10);
49
+ }
50
+
51
+ const requestsRemaining = response.headers.get("X-RateLimit-Requests-Remaining");
52
+ if (requestsRemaining !== null) {
53
+ this.rateLimitState.requestsRemaining = parseInt(requestsRemaining, 10);
54
+ }
55
+
56
+ const requestsReset = response.headers.get("X-RateLimit-Requests-Reset");
57
+ if (requestsReset !== null) {
58
+ this.rateLimitState.requestsReset = parseInt(requestsReset, 10) * 1000;
59
+ }
60
+
61
+ const complexityLimit = response.headers.get("X-RateLimit-Complexity-Limit");
62
+ if (complexityLimit !== null) {
63
+ this.rateLimitState.complexityLimit = parseInt(complexityLimit, 10);
64
+ }
65
+
66
+ const complexityRemaining = response.headers.get("X-RateLimit-Complexity-Remaining");
67
+ if (complexityRemaining !== null) {
68
+ this.rateLimitState.complexityRemaining = parseInt(complexityRemaining, 10);
69
+ }
70
+
71
+ const complexityReset = response.headers.get("X-RateLimit-Complexity-Reset");
72
+ if (complexityReset !== null) {
73
+ this.rateLimitState.complexityReset = parseInt(complexityReset, 10) * 1000;
74
+ }
75
+ }
76
+
77
+ // ── Core GraphQL ─────────────────────────────────────────────
78
+
79
+ async graphql<T = unknown>(
80
+ query: string,
81
+ variables: Record<string, unknown> = {}
82
+ ): Promise<GraphQLResponse<T>> {
83
+ const payload: Record<string, unknown> = { query };
84
+ if (Object.keys(variables).length > 0) {
85
+ payload.variables = variables;
86
+ }
87
+
88
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
89
+ // Proactive throttling when remaining requests drop below 10%
90
+ const { requestsRemaining, requestsLimit, requestsReset } = this.rateLimitState;
91
+ const ratio = requestsLimit > 0 ? requestsRemaining / requestsLimit : 1;
92
+
93
+ if (ratio < 0.1 && requestsRemaining > 0) {
94
+ const resetMs = Math.max(0, requestsReset - Date.now());
95
+ const delay = Math.min(5000, Math.ceil(resetMs / requestsRemaining));
96
+ if (delay > 100) {
97
+ this.onThrottle?.(requestsRemaining, requestsLimit);
98
+ await Bun.sleep(delay);
99
+ }
100
+ }
101
+
102
+ const controller = new AbortController();
103
+ const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
104
+
105
+ let response: Response;
106
+ try {
107
+ response = await fetch(this.endpoint, {
108
+ method: "POST",
109
+ headers: {
110
+ Authorization: this.apiKey,
111
+ "Content-Type": "application/json",
112
+ },
113
+ body: JSON.stringify(payload),
114
+ signal: controller.signal,
115
+ });
116
+ } catch (err) {
117
+ clearTimeout(timeout);
118
+ if ((err as Error).name === "AbortError") {
119
+ throw new TrackerError("linear_api_request", "Request timed out");
120
+ }
121
+ throw new TrackerError("linear_api_request", `Network error: ${(err as Error).message}`);
122
+ } finally {
123
+ clearTimeout(timeout);
124
+ }
125
+
126
+ this.parseRateLimitHeaders(response);
127
+
128
+ const data = await response.json() as GraphQLResponse<T>;
129
+
130
+ // Detect rate limit
131
+ const isRateLimited = data.errors?.[0]?.extensions?.code === "RATELIMITED";
132
+
133
+ if (isRateLimited) {
134
+ if (attempt === MAX_RETRIES) {
135
+ const waitSecs = Math.max(1, Math.ceil((this.rateLimitState.requestsReset - Date.now()) / 1000));
136
+ throw new TrackerError("linear_api_request", `Rate limited by Linear. Retry after ${waitSecs}s`);
137
+ }
138
+
139
+ const resetMs = this.rateLimitState.requestsReset - Date.now();
140
+ const waitMs = Math.max(500, resetMs) + Math.random() * 1500 + 500;
141
+ this.onRateLimit?.(attempt, Math.ceil(waitMs / 1000));
142
+ await Bun.sleep(waitMs);
143
+ continue;
144
+ }
145
+
146
+ if (!response.ok) {
147
+ throw new TrackerError(
148
+ "linear_api_status",
149
+ `API request failed with status ${response.status}: ${JSON.stringify(data)}`
150
+ );
151
+ }
152
+
153
+ if (data.errors && data.errors.length > 0) {
154
+ const msg = data.errors[0]?.message ?? "Unknown GraphQL error";
155
+ throw new TrackerError("linear_graphql_errors", `GraphQL error: ${msg}`);
156
+ }
157
+
158
+ return data;
159
+ }
160
+
161
+ throw new TrackerError("linear_api_request", "Max retries exceeded");
162
+ }
163
+
164
+ // ── Issue Normalization ──────────────────────────────────────
165
+
166
+ private normalizeIssue(node: LinearIssueNode): Issue {
167
+ // Extract blockers from inverse relations where type is "blocks"
168
+ const blockers: BlockerRef[] = [];
169
+ if (node.inverseRelations?.nodes) {
170
+ for (const rel of node.inverseRelations.nodes) {
171
+ if (rel.type === "blocks") {
172
+ blockers.push({
173
+ id: rel.issue.id,
174
+ identifier: rel.issue.identifier,
175
+ state: rel.issue.state.name,
176
+ });
177
+ }
178
+ }
179
+ }
180
+
181
+ // Normalize children/subtasks
182
+ const children: ChildIssue[] = [];
183
+ if (node.children?.nodes) {
184
+ for (const child of node.children.nodes) {
185
+ children.push({
186
+ id: child.id,
187
+ identifier: child.identifier,
188
+ title: child.title,
189
+ description: child.description,
190
+ priority: typeof child.priority === "number" ? child.priority : null,
191
+ state: child.state.name,
192
+ state_type: child.state.type,
193
+ sort_order: child.subIssueSortOrder ?? 0,
194
+ assignee: child.assignee?.name ?? null,
195
+ created_at: child.createdAt ? new Date(child.createdAt) : null,
196
+ updated_at: child.updatedAt ? new Date(child.updatedAt) : null,
197
+ });
198
+ }
199
+ // Sort by sort_order
200
+ children.sort((a, b) => a.sort_order - b.sort_order);
201
+ }
202
+
203
+ // Normalize comments
204
+ const comments: Comment[] = [];
205
+ if (node.comments?.nodes) {
206
+ for (const c of node.comments.nodes) {
207
+ comments.push({
208
+ id: c.id,
209
+ body: c.body,
210
+ user: c.user?.name ?? null,
211
+ created_at: c.createdAt ? new Date(c.createdAt) : null,
212
+ });
213
+ }
214
+ }
215
+
216
+ return {
217
+ id: node.id,
218
+ identifier: node.identifier,
219
+ title: node.title,
220
+ description: node.description,
221
+ priority: typeof node.priority === "number" ? node.priority : null,
222
+ state: node.state.name,
223
+ branch_name: node.branchName,
224
+ url: node.url,
225
+ labels: node.labels.nodes.map((l) => l.name.toLowerCase()),
226
+ blocked_by: blockers,
227
+ children,
228
+ comments,
229
+ created_at: node.createdAt ? new Date(node.createdAt) : null,
230
+ updated_at: node.updatedAt ? new Date(node.updatedAt) : null,
231
+ };
232
+ }
233
+
234
+ // ── Tracker Operations ───────────────────────────────────────
235
+
236
+ /**
237
+ * Fetch candidate issues in active states for a project
238
+ */
239
+ async fetchCandidateIssues(projectSlug: string, activeStates: string[]): Promise<Issue[]> {
240
+ const issues: Issue[] = [];
241
+ let cursor: string | null = null;
242
+
243
+ do {
244
+ const response: GraphQLResponse<LinearIssuesData> = await this.graphql(Q.FETCH_CANDIDATE_ISSUES, {
245
+ projectSlug,
246
+ states: activeStates,
247
+ after: cursor,
248
+ });
249
+
250
+ if (!response.data?.issues) {
251
+ throw new TrackerError("linear_unknown_payload", "Unexpected response structure from Linear");
252
+ }
253
+
254
+ for (const node of response.data.issues.nodes) {
255
+ issues.push(this.normalizeIssue(node));
256
+ }
257
+
258
+ if (response.data.issues.pageInfo.hasNextPage) {
259
+ if (!response.data.issues.pageInfo.endCursor) {
260
+ throw new TrackerError("linear_missing_end_cursor", "Pagination cursor missing");
261
+ }
262
+ cursor = response.data.issues.pageInfo.endCursor;
263
+ } else {
264
+ cursor = null;
265
+ }
266
+ } while (cursor);
267
+
268
+ return issues;
269
+ }
270
+
271
+ /**
272
+ * Fetch issues in specified states (for startup terminal cleanup)
273
+ */
274
+ async fetchIssuesByStates(projectSlug: string, states: string[]): Promise<Issue[]> {
275
+ const issues: Issue[] = [];
276
+ let cursor: string | null = null;
277
+
278
+ do {
279
+ const response: GraphQLResponse<LinearIssuesData> = await this.graphql(Q.FETCH_ISSUES_BY_STATES, {
280
+ projectSlug,
281
+ states,
282
+ after: cursor,
283
+ });
284
+
285
+ if (!response.data?.issues) {
286
+ throw new TrackerError("linear_unknown_payload", "Unexpected response structure from Linear");
287
+ }
288
+
289
+ for (const node of response.data.issues.nodes) {
290
+ issues.push(this.normalizeIssue(node));
291
+ }
292
+
293
+ if (response.data.issues.pageInfo.hasNextPage) {
294
+ if (!response.data.issues.pageInfo.endCursor) {
295
+ throw new TrackerError("linear_missing_end_cursor", "Pagination cursor missing");
296
+ }
297
+ cursor = response.data.issues.pageInfo.endCursor;
298
+ } else {
299
+ cursor = null;
300
+ }
301
+ } while (cursor);
302
+
303
+ return issues;
304
+ }
305
+
306
+ /**
307
+ * Fetch current states for specific issue IDs (for reconciliation)
308
+ */
309
+ async fetchIssueStatesByIds(issueIds: string[]): Promise<Map<string, string>> {
310
+ if (issueIds.length === 0) {
311
+ return new Map();
312
+ }
313
+
314
+ const response: GraphQLResponse<LinearIssueStatesData> = await this.graphql(Q.FETCH_ISSUES_BY_IDS, {
315
+ ids: issueIds,
316
+ });
317
+
318
+ if (!response.data?.issues) {
319
+ throw new TrackerError("linear_unknown_payload", "Unexpected response structure from Linear");
320
+ }
321
+
322
+ const stateMap = new Map<string, string>();
323
+ for (const node of response.data.issues.nodes) {
324
+ stateMap.set(node.id, node.state.name);
325
+ }
326
+
327
+ return stateMap;
328
+ }
329
+
330
+ // ── CRUD Operations (used by linear CLI) ───────────────────────
331
+
332
+ /**
333
+ * Get a single issue by identifier (e.g. "SYM-123")
334
+ */
335
+ async getIssue(identifier: string): Promise<{
336
+ id: string;
337
+ identifier: string;
338
+ title: string;
339
+ description: string | null;
340
+ state: { id: string; name: string; type: string };
341
+ team: { id: string };
342
+ labels: { nodes: Array<{ id: string; name: string }> };
343
+ children: { nodes: Array<{ id: string; identifier: string; title: string; description: string | null; priority: number | null; state: { id: string; name: string; type: string } }> };
344
+ comments: { nodes: Array<{ id: string; body: string; createdAt: string; user?: { name: string } | null }> };
345
+ } | null> {
346
+ const response = await this.graphql<{ issue: any }>(Q.GET_ISSUE, { identifier });
347
+ return response.data?.issue ?? null;
348
+ }
349
+
350
+ /**
351
+ * Get comments for an issue (up to 50)
352
+ */
353
+ async getComments(identifier: string): Promise<Array<{ id: string; body: string; createdAt: string; user: string | null }>> {
354
+ const issue = await this.getIssue(identifier);
355
+ if (!issue) return [];
356
+
357
+ const response = await this.graphql<{ issue: { comments: { nodes: Array<{ id: string; body: string; createdAt: string; user?: { name: string } | null }> } } }>(Q.GET_COMMENTS, { issueId: issue.id });
358
+ const nodes = response.data?.issue?.comments?.nodes ?? [];
359
+ return nodes.map(c => ({
360
+ id: c.id,
361
+ body: c.body,
362
+ createdAt: c.createdAt,
363
+ user: c.user?.name ?? null,
364
+ }));
365
+ }
366
+
367
+ /**
368
+ * Create a new issue
369
+ */
370
+ async createIssue(input: Record<string, unknown>): Promise<{ id: string; identifier: string; title: string; url: string }> {
371
+ const response = await this.graphql<{ issueCreate: { success: boolean; issue: any } }>(Q.CREATE_ISSUE, { input });
372
+ if (!response.data?.issueCreate?.success) {
373
+ throw new TrackerError("linear_api_request", "Failed to create issue");
374
+ }
375
+ return response.data.issueCreate.issue;
376
+ }
377
+
378
+ /**
379
+ * Update an existing issue
380
+ */
381
+ async updateIssue(issueId: string, input: Record<string, unknown>): Promise<void> {
382
+ const response = await this.graphql<{ issueUpdate: { success: boolean } }>(Q.UPDATE_ISSUE, { issueId, input });
383
+ if (!response.data?.issueUpdate?.success) {
384
+ throw new TrackerError("linear_api_request", "Failed to update issue");
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Create a comment on an issue
390
+ */
391
+ async createComment(issueId: string, body: string): Promise<string> {
392
+ const response = await this.graphql<{ commentCreate: { success: boolean; comment: { id: string } } }>(Q.CREATE_COMMENT, { issueId, body });
393
+ if (!response.data?.commentCreate?.success) {
394
+ throw new TrackerError("linear_api_request", "Failed to create comment");
395
+ }
396
+ return response.data.commentCreate.comment.id;
397
+ }
398
+
399
+ /**
400
+ * Update an existing comment
401
+ */
402
+ async updateComment(commentId: string, body: string): Promise<void> {
403
+ const response = await this.graphql<{ commentUpdate: { success: boolean } }>(Q.UPDATE_COMMENT, { commentId, body });
404
+ if (!response.data?.commentUpdate?.success) {
405
+ throw new TrackerError("linear_api_request", "Failed to update comment");
406
+ }
407
+ }
408
+
409
+ /**
410
+ * Create or update a comment. If commentId is provided and valid, updates it; otherwise creates new.
411
+ */
412
+ async upsertComment(issueId: string, body: string, commentId: string | null): Promise<string> {
413
+ if (commentId) {
414
+ try {
415
+ await this.updateComment(commentId, body);
416
+ return commentId;
417
+ } catch {
418
+ // Comment may have been deleted — fall through to create
419
+ }
420
+ }
421
+ return this.createComment(issueId, body);
422
+ }
423
+
424
+ /**
425
+ * Get labels on an issue
426
+ */
427
+ async getIssueLabels(issueId: string): Promise<Array<{ id: string; name: string }>> {
428
+ const response = await this.graphql<{ issue: { labels: { nodes: Array<{ id: string; name: string }> } } }>(Q.GET_ISSUE_LABELS, { issueId });
429
+ return response.data?.issue?.labels?.nodes ?? [];
430
+ }
431
+
432
+ /**
433
+ * Set all labels on an issue (replaces existing)
434
+ */
435
+ async setIssueLabels(issueId: string, labelIds: string[]): Promise<void> {
436
+ await this.updateIssue(issueId, { labelIds });
437
+ }
438
+
439
+ /**
440
+ * Atomically swap one label for another on an issue
441
+ */
442
+ async swapLabel(issueId: string, removeLabelName: string, addLabelName: string, teamId: string): Promise<void> {
443
+ const currentLabels = await this.getIssueLabels(issueId);
444
+ const currentIds = currentLabels.map(l => l.id);
445
+
446
+ // Resolve label names to IDs
447
+ const removeLabel = currentLabels.find(l => l.name.toLowerCase() === removeLabelName.toLowerCase());
448
+ if (!removeLabel) {
449
+ throw new TrackerError("linear_api_request", `Label "${removeLabelName}" not found on issue`);
450
+ }
451
+
452
+ // Find or create the add label
453
+ const addLabelId = await this.ensureLabel(teamId, addLabelName);
454
+
455
+ const newIds = currentIds.filter(id => id !== removeLabel.id);
456
+ if (!newIds.includes(addLabelId)) {
457
+ newIds.push(addLabelId);
458
+ }
459
+
460
+ await this.setIssueLabels(issueId, newIds);
461
+ }
462
+
463
+ /**
464
+ * Add a label to an issue (by name, creates if needed)
465
+ */
466
+ async addLabel(issueId: string, labelName: string, teamId: string): Promise<void> {
467
+ const currentLabels = await this.getIssueLabels(issueId);
468
+ const alreadyHas = currentLabels.some(l => l.name.toLowerCase() === labelName.toLowerCase());
469
+ if (alreadyHas) return;
470
+
471
+ const labelId = await this.ensureLabel(teamId, labelName);
472
+ const newIds = [...currentLabels.map(l => l.id), labelId];
473
+ await this.setIssueLabels(issueId, newIds);
474
+ }
475
+
476
+ /**
477
+ * Remove a label from an issue (by name)
478
+ */
479
+ async removeLabel(issueId: string, labelName: string): Promise<void> {
480
+ const currentLabels = await this.getIssueLabels(issueId);
481
+ const removeLabel = currentLabels.find(l => l.name.toLowerCase() === labelName.toLowerCase());
482
+ if (!removeLabel) return;
483
+
484
+ const newIds = currentLabels.map(l => l.id).filter(id => id !== removeLabel.id);
485
+ await this.setIssueLabels(issueId, newIds);
486
+ }
487
+
488
+ /**
489
+ * Ensure a label exists on a team, returning its ID
490
+ */
491
+ async ensureLabel(teamId: string, labelName: string): Promise<string> {
492
+ const response = await this.graphql<{ team: { labels: { nodes: Array<{ id: string; name: string }> } } }>(Q.GET_TEAM_LABELS, { teamId });
493
+ const labels = response.data?.team?.labels?.nodes ?? [];
494
+
495
+ for (const label of labels) {
496
+ if (label.name.toLowerCase() === labelName.toLowerCase()) {
497
+ return label.id;
498
+ }
499
+ }
500
+
501
+ // Create it
502
+ const createResponse = await this.graphql<{ issueLabelCreate: { success: boolean; issueLabel: { id: string } } }>(Q.CREATE_LABEL, {
503
+ input: { teamId, name: labelName, color: "#888888" },
504
+ });
505
+
506
+ if (!createResponse.data?.issueLabelCreate?.success) {
507
+ throw new TrackerError("linear_api_request", `Failed to create label "${labelName}"`);
508
+ }
509
+
510
+ return createResponse.data.issueLabelCreate.issueLabel.id;
511
+ }
512
+
513
+ /**
514
+ * Get attachments for an issue
515
+ */
516
+ async getAttachments(issueId: string): Promise<Array<{ id: string; title: string | null; url: string }>> {
517
+ const response = await this.graphql<{ issue: { attachments: { nodes: Array<{ id: string; title: string | null; url: string }> } } }>(Q.GET_ISSUE_ATTACHMENTS, { issueId });
518
+ return response.data?.issue?.attachments?.nodes ?? [];
519
+ }
520
+
521
+ /**
522
+ * Find a state ID by name within a team
523
+ */
524
+ async findStateId(teamId: string, stateName: string): Promise<string | null> {
525
+ const response = await this.graphql<{ team: { states: { nodes: Array<{ id: string; name: string }> } } }>(Q.FIND_STATE_ID, { teamId });
526
+ const states = response.data?.team?.states?.nodes ?? [];
527
+
528
+ for (const state of states) {
529
+ if (state.name.toLowerCase() === stateName.toLowerCase()) {
530
+ return state.id;
531
+ }
532
+ }
533
+
534
+ return null;
535
+ }
536
+
537
+ /**
538
+ * Execute arbitrary GraphQL query (for linear_graphql tool)
539
+ */
540
+ async executeGraphQL(
541
+ query: string,
542
+ variables?: Record<string, unknown>
543
+ ): Promise<{ success: boolean; data?: unknown; error?: string }> {
544
+ // Validate query is non-empty
545
+ if (!query || !query.trim()) {
546
+ return { success: false, error: "Query must be a non-empty string" };
547
+ }
548
+
549
+ // Simple check for multiple operations (naive but catches common cases)
550
+ const operationMatches = query.match(/\b(query|mutation|subscription)\b/gi);
551
+ if (operationMatches && operationMatches.length > 1) {
552
+ return {
553
+ success: false,
554
+ error: "Query must contain exactly one GraphQL operation",
555
+ };
556
+ }
557
+
558
+ try {
559
+ const response = await this.graphql<unknown>(query, variables || {});
560
+
561
+ if (response.errors && response.errors.length > 0) {
562
+ return {
563
+ success: false,
564
+ data: response.data,
565
+ error: response.errors.map((e) => e.message).join("; "),
566
+ };
567
+ }
568
+
569
+ return { success: true, data: response.data };
570
+ } catch (err) {
571
+ return {
572
+ success: false,
573
+ error: (err as Error).message,
574
+ };
575
+ }
576
+ }
577
+ }