@cliangdev/flux-plugin 0.2.0 → 0.3.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 (108) hide show
  1. package/README.md +11 -7
  2. package/agents/coder.md +150 -25
  3. package/bin/install.cjs +171 -16
  4. package/commands/breakdown.md +47 -10
  5. package/commands/dashboard.md +29 -0
  6. package/commands/flux.md +92 -12
  7. package/commands/implement.md +166 -17
  8. package/commands/linear.md +6 -5
  9. package/commands/prd.md +996 -82
  10. package/manifest.json +2 -1
  11. package/package.json +9 -11
  12. package/skills/flux-orchestrator/SKILL.md +11 -3
  13. package/skills/prd-writer/SKILL.md +761 -0
  14. package/skills/ux-ui-design/SKILL.md +346 -0
  15. package/skills/ux-ui-design/references/design-tokens.md +359 -0
  16. package/src/__tests__/version.test.ts +37 -0
  17. package/src/adapters/local/.gitkeep +0 -0
  18. package/src/dashboard/__tests__/api.test.ts +211 -0
  19. package/src/dashboard/browser.ts +35 -0
  20. package/src/dashboard/public/app.js +869 -0
  21. package/src/dashboard/public/index.html +90 -0
  22. package/src/dashboard/public/styles.css +807 -0
  23. package/src/dashboard/public/vendor/highlight.css +10 -0
  24. package/src/dashboard/public/vendor/highlight.min.js +8422 -0
  25. package/src/dashboard/public/vendor/marked.min.js +2210 -0
  26. package/src/dashboard/server.ts +296 -0
  27. package/src/dashboard/watchers.ts +83 -0
  28. package/src/server/__tests__/config.test.ts +163 -0
  29. package/src/server/adapters/__tests__/a-client-linear.test.ts +197 -0
  30. package/src/server/adapters/__tests__/adapter-factory.test.ts +230 -0
  31. package/src/server/adapters/__tests__/dependency-ops.test.ts +429 -0
  32. package/src/server/adapters/__tests__/document-ops.test.ts +306 -0
  33. package/src/server/adapters/__tests__/linear-adapter.test.ts +91 -0
  34. package/src/server/adapters/__tests__/linear-config.test.ts +425 -0
  35. package/src/server/adapters/__tests__/linear-criteria-parser.test.ts +287 -0
  36. package/src/server/adapters/__tests__/linear-description-test.ts +238 -0
  37. package/src/server/adapters/__tests__/linear-epic-crud.test.ts +496 -0
  38. package/src/server/adapters/__tests__/linear-mappers-description.test.ts +276 -0
  39. package/src/server/adapters/__tests__/linear-mappers-epic.test.ts +294 -0
  40. package/src/server/adapters/__tests__/linear-mappers-prd.test.ts +300 -0
  41. package/src/server/adapters/__tests__/linear-mappers-task.test.ts +197 -0
  42. package/src/server/adapters/__tests__/linear-prd-crud.test.ts +620 -0
  43. package/src/server/adapters/__tests__/linear-stats.test.ts +450 -0
  44. package/src/server/adapters/__tests__/linear-task-crud.test.ts +534 -0
  45. package/src/server/adapters/__tests__/linear-types.test.ts +243 -0
  46. package/src/server/adapters/__tests__/status-ops.test.ts +441 -0
  47. package/src/server/adapters/factory.ts +90 -0
  48. package/src/server/adapters/index.ts +9 -0
  49. package/src/server/adapters/linear/adapter.ts +1141 -0
  50. package/src/server/adapters/linear/client.ts +169 -0
  51. package/src/server/adapters/linear/config.ts +152 -0
  52. package/src/server/adapters/linear/helpers/criteria-parser.ts +197 -0
  53. package/src/server/adapters/linear/helpers/index.ts +7 -0
  54. package/src/server/adapters/linear/index.ts +16 -0
  55. package/src/server/adapters/linear/mappers/description.ts +136 -0
  56. package/src/server/adapters/linear/mappers/epic.ts +81 -0
  57. package/src/server/adapters/linear/mappers/index.ts +27 -0
  58. package/src/server/adapters/linear/mappers/prd.ts +178 -0
  59. package/src/server/adapters/linear/mappers/task.ts +82 -0
  60. package/src/server/adapters/linear/types.ts +264 -0
  61. package/src/server/adapters/local-adapter.ts +1009 -0
  62. package/src/server/adapters/types.ts +293 -0
  63. package/src/server/config.ts +73 -0
  64. package/src/server/db/__tests__/queries.test.ts +473 -0
  65. package/src/server/db/ids.ts +17 -0
  66. package/src/server/db/index.ts +69 -0
  67. package/src/server/db/queries.ts +142 -0
  68. package/src/server/db/refs.ts +60 -0
  69. package/src/server/db/schema.ts +97 -0
  70. package/src/server/db/sqlite.ts +10 -0
  71. package/src/server/index.ts +81 -0
  72. package/src/server/tools/__tests__/crud.test.ts +411 -0
  73. package/src/server/tools/__tests__/get-version.test.ts +27 -0
  74. package/src/server/tools/__tests__/mcp-interface.test.ts +479 -0
  75. package/src/server/tools/__tests__/query.test.ts +405 -0
  76. package/src/server/tools/__tests__/z-configure-linear.test.ts +511 -0
  77. package/src/server/tools/__tests__/z-get-linear-url.test.ts +108 -0
  78. package/src/server/tools/configure-linear.ts +373 -0
  79. package/src/server/tools/create-epic.ts +44 -0
  80. package/src/server/tools/create-prd.ts +40 -0
  81. package/src/server/tools/create-task.ts +47 -0
  82. package/src/server/tools/criteria.ts +50 -0
  83. package/src/server/tools/delete-entity.ts +76 -0
  84. package/src/server/tools/dependencies.ts +55 -0
  85. package/src/server/tools/get-entity.ts +240 -0
  86. package/src/server/tools/get-linear-url.ts +28 -0
  87. package/src/server/tools/get-stats.ts +52 -0
  88. package/src/server/tools/get-version.ts +20 -0
  89. package/src/server/tools/index.ts +158 -0
  90. package/src/server/tools/init-project.ts +108 -0
  91. package/src/server/tools/query-entities.ts +167 -0
  92. package/src/server/tools/render-status.ts +219 -0
  93. package/src/server/tools/update-entity.ts +140 -0
  94. package/src/server/tools/update-status.ts +166 -0
  95. package/src/server/utils/__tests__/mcp-response.test.ts +331 -0
  96. package/src/server/utils/logger.ts +9 -0
  97. package/src/server/utils/mcp-response.ts +254 -0
  98. package/src/server/utils/status-transitions.ts +160 -0
  99. package/src/status-line/__tests__/status-line.test.ts +215 -0
  100. package/src/status-line/index.ts +147 -0
  101. package/src/utils/__tests__/chalk-import.test.ts +32 -0
  102. package/src/utils/__tests__/display.test.ts +97 -0
  103. package/src/utils/__tests__/status-renderer.test.ts +310 -0
  104. package/src/utils/display.ts +62 -0
  105. package/src/utils/status-renderer.ts +214 -0
  106. package/src/version.ts +5 -0
  107. package/dist/server/index.js +0 -87063
  108. package/skills/prd-template/SKILL.md +0 -242
@@ -0,0 +1,1141 @@
1
+ /**
2
+ * Linear Adapter
3
+ *
4
+ * Implements BackendAdapter for Linear API integration.
5
+ *
6
+ * Entity Mapping:
7
+ * - Flux Project → Linear Project (configured during configure_linear)
8
+ * - PRD → Linear Issue with "prd" label in configured project
9
+ * - Epic → Linear Issue child of PRD with "epic" label
10
+ * - Task → Linear Issue child of Epic with "task" label
11
+ */
12
+
13
+ import type {
14
+ AcceptanceCriterion,
15
+ AddCriterionInput,
16
+ BackendAdapter,
17
+ CascadeResult,
18
+ CreateEpicInput,
19
+ CreatePrdInput,
20
+ CreateTaskInput,
21
+ Document,
22
+ Epic,
23
+ EpicFilters,
24
+ PaginatedResult,
25
+ PaginationOptions,
26
+ Prd,
27
+ PrdFilters,
28
+ Stats,
29
+ Task,
30
+ TaskFilters,
31
+ UpdateEpicInput,
32
+ UpdatePrdInput,
33
+ UpdateTaskInput,
34
+ } from "../types.js";
35
+ import { LinearClient } from "./client.js";
36
+ import { formatDescription } from "./mappers/description.js";
37
+ import { toFluxEpicStatus, toLinearEpicState } from "./mappers/epic.js";
38
+ import {
39
+ getAllStatusLabels,
40
+ getStatusLabelForPrdStatus,
41
+ toFluxPrdStatusFromIssue,
42
+ toLinearPrdIssueState,
43
+ } from "./mappers/prd.js";
44
+ import {
45
+ toFluxPriority,
46
+ toFluxTaskStatus,
47
+ toLinearPriority,
48
+ toLinearTaskState,
49
+ } from "./mappers/task.js";
50
+ import {
51
+ extractTagFromLabels,
52
+ FLUX_MILESTONE_LABEL_PREFIX,
53
+ getMilestoneLabel,
54
+ type LinearConfig,
55
+ } from "./types.js";
56
+
57
+ /**
58
+ * Hydrated issue with all lazy-loaded fields resolved.
59
+ * This eliminates repeated async calls throughout the adapter.
60
+ */
61
+ export interface HydratedIssue {
62
+ id: string;
63
+ identifier: string;
64
+ title: string;
65
+ description?: string;
66
+ stateName: string;
67
+ stateType?: string;
68
+ labels: string[];
69
+ parentIdentifier?: string;
70
+ priority: number;
71
+ createdAt: Date;
72
+ updatedAt: Date;
73
+ // Raw issue for operations that need it (update, archive, etc.)
74
+ _raw: any;
75
+ }
76
+
77
+ export class LinearAdapter implements BackendAdapter {
78
+ private config: LinearConfig;
79
+ private client: LinearClient;
80
+
81
+ constructor(config: LinearConfig) {
82
+ this.config = config;
83
+ this.client = new LinearClient(config);
84
+ }
85
+
86
+ // -------------------------------------------------------------------------
87
+ // Helper Methods
88
+ // -------------------------------------------------------------------------
89
+
90
+ private extractIssueNumber(identifier: string): number {
91
+ const match = identifier.match(/-(\d+)$/);
92
+ if (!match) {
93
+ throw new Error(`Invalid issue identifier format: ${identifier}`);
94
+ }
95
+ return parseInt(match[1], 10);
96
+ }
97
+
98
+ /**
99
+ * Hydrate a raw Linear issue by resolving all lazy-loaded fields in parallel.
100
+ */
101
+ async hydrateIssue(issue: any): Promise<HydratedIssue> {
102
+ const [state, labelsResult, parent] = await Promise.all([
103
+ this.client.execute<any>(() => issue.state),
104
+ this.client.execute<any>(() => issue.labels()),
105
+ issue.parent
106
+ ? this.client.execute<any>(() => issue.parent)
107
+ : Promise.resolve(null),
108
+ ]);
109
+
110
+ return {
111
+ id: issue.id,
112
+ identifier: issue.identifier,
113
+ title: issue.title,
114
+ description: issue.description,
115
+ stateName: state?.name || "Backlog",
116
+ stateType: state?.type,
117
+ labels: (labelsResult?.nodes || []).map((l: any) => l.name),
118
+ parentIdentifier: parent?.identifier,
119
+ priority: issue.priority ?? 3,
120
+ createdAt: issue.createdAt,
121
+ updatedAt: issue.updatedAt,
122
+ _raw: issue,
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Fetch a single issue by ref and hydrate it.
128
+ */
129
+ async fetchIssue(ref: string): Promise<HydratedIssue | null> {
130
+ const issueNumber = this.extractIssueNumber(ref);
131
+ const result = await this.client.execute(() =>
132
+ this.client.client.issues({
133
+ filter: {
134
+ number: { eq: issueNumber },
135
+ team: { id: { eq: this.config.teamId } },
136
+ },
137
+ }),
138
+ );
139
+
140
+ const issue = result.nodes[0];
141
+ if (!issue) return null;
142
+
143
+ return this.hydrateIssue(issue);
144
+ }
145
+
146
+ /**
147
+ * Fetch multiple issues with a filter and hydrate them.
148
+ */
149
+ async fetchIssues(filter: any, limit: number): Promise<HydratedIssue[]> {
150
+ const result = await this.client.execute(() =>
151
+ this.client.client.issues({ filter, first: limit }),
152
+ );
153
+
154
+ return Promise.all(
155
+ result.nodes.map((issue: any) => this.hydrateIssue(issue)),
156
+ );
157
+ }
158
+
159
+ /**
160
+ * Get label ID by name.
161
+ */
162
+ private async getLabelId(labelName: string): Promise<string> {
163
+ const labelsResult = await this.client.execute(() =>
164
+ this.client.client.issueLabels({
165
+ filter: {
166
+ name: { eq: labelName },
167
+ team: { id: { eq: this.config.teamId } },
168
+ },
169
+ }),
170
+ );
171
+
172
+ const label = labelsResult.nodes[0];
173
+ if (!label) {
174
+ throw new Error(`Label "${labelName}" not found in team`);
175
+ }
176
+
177
+ return label.id;
178
+ }
179
+
180
+ /**
181
+ * Get workflow state ID by name.
182
+ */
183
+ private async getStateId(stateName: string): Promise<string> {
184
+ const team = await this.client.execute(() =>
185
+ this.client.client.team(this.config.teamId),
186
+ );
187
+ const states = await this.client.execute(() => team.states());
188
+ const targetState = states.nodes.find((s: any) => s.name === stateName);
189
+ if (!targetState) {
190
+ throw new Error(`Workflow state '${stateName}' not found`);
191
+ }
192
+ return targetState.id;
193
+ }
194
+
195
+ // Converters from HydratedIssue to domain types
196
+
197
+ private toPrd(issue: HydratedIssue): Prd {
198
+ return {
199
+ id: issue.id,
200
+ projectId: this.config.projectId,
201
+ ref: issue.identifier,
202
+ title: issue.title,
203
+ description: issue.description,
204
+ status: toFluxPrdStatusFromIssue(issue.stateName, issue.labels),
205
+ tag: extractTagFromLabels(issue.labels),
206
+ createdAt: issue.createdAt.toISOString(),
207
+ updatedAt: issue.updatedAt.toISOString(),
208
+ };
209
+ }
210
+
211
+ private toEpic(issue: HydratedIssue): Epic {
212
+ return {
213
+ id: issue.id,
214
+ prdId: issue.parentIdentifier || "",
215
+ ref: issue.identifier,
216
+ title: issue.title,
217
+ description: issue.description,
218
+ status: toFluxEpicStatus(issue.stateName),
219
+ createdAt: issue.createdAt.toISOString(),
220
+ updatedAt: issue.updatedAt.toISOString(),
221
+ };
222
+ }
223
+
224
+ private toTask(issue: HydratedIssue): Task {
225
+ return {
226
+ id: issue.id,
227
+ epicId: issue.parentIdentifier || "",
228
+ ref: issue.identifier,
229
+ title: issue.title,
230
+ description: issue.description,
231
+ status: toFluxTaskStatus(issue.stateName),
232
+ priority: toFluxPriority(issue.priority),
233
+ createdAt: issue.createdAt.toISOString(),
234
+ updatedAt: issue.updatedAt.toISOString(),
235
+ };
236
+ }
237
+
238
+ // Label checkers
239
+
240
+ private isPrd(issue: HydratedIssue): boolean {
241
+ return issue.labels.includes(this.config.defaultLabels.prd);
242
+ }
243
+
244
+ private isEpic(issue: HydratedIssue): boolean {
245
+ return issue.labels.includes(this.config.defaultLabels.epic);
246
+ }
247
+
248
+ private isTask(issue: HydratedIssue): boolean {
249
+ return issue.labels.includes(this.config.defaultLabels.task);
250
+ }
251
+
252
+ // -------------------------------------------------------------------------
253
+ // PRD Operations
254
+ // -------------------------------------------------------------------------
255
+
256
+ async createPrd(input: CreatePrdInput): Promise<Prd> {
257
+ const labelIds = [await this.getLabelId(this.config.defaultLabels.prd)];
258
+
259
+ // Add milestone label if tag provided
260
+ if (input.tag) {
261
+ const tagLabelId = await this.getOrCreateLabel(
262
+ getMilestoneLabel(input.tag),
263
+ );
264
+ labelIds.push(tagLabelId);
265
+ }
266
+
267
+ const createResult = await this.client.execute(() =>
268
+ this.client.client.createIssue({
269
+ title: input.title,
270
+ description: input.description,
271
+ teamId: this.config.teamId,
272
+ projectId: this.config.projectId,
273
+ labelIds,
274
+ }),
275
+ );
276
+
277
+ const rawIssue = await this.client.execute<any>(
278
+ () => (createResult as any).issue,
279
+ );
280
+ if (!rawIssue) {
281
+ throw new Error("Failed to create PRD issue");
282
+ }
283
+
284
+ const issue = await this.hydrateIssue(rawIssue);
285
+ return this.toPrd(issue);
286
+ }
287
+
288
+ async updatePrd(ref: string, input: UpdatePrdInput): Promise<Prd> {
289
+ const issue = await this.fetchIssue(ref);
290
+ if (!issue) throw new Error(`PRD not found: ${ref}`);
291
+ if (!this.isPrd(issue)) throw new Error(`Issue ${ref} is not a PRD`);
292
+
293
+ const updatePayload: any = {};
294
+ if (input.title !== undefined) updatePayload.title = input.title;
295
+ if (input.description !== undefined)
296
+ updatePayload.description = input.description;
297
+
298
+ // Handle status change
299
+ if (input.status !== undefined) {
300
+ updatePayload.stateId = await this.getStateId(
301
+ toLinearPrdIssueState(input.status),
302
+ );
303
+ }
304
+
305
+ // Handle tag and/or status change - both require label updates
306
+ if (input.status !== undefined || input.tag !== undefined) {
307
+ const currentStatus = toFluxPrdStatusFromIssue(
308
+ issue.stateName,
309
+ issue.labels,
310
+ );
311
+ updatePayload.labelIds = await this.buildPrdLabelIdsWithTag(
312
+ issue.labels,
313
+ input.status ?? currentStatus,
314
+ input.tag !== undefined
315
+ ? input.tag
316
+ : extractTagFromLabels(issue.labels),
317
+ );
318
+ }
319
+
320
+ await this.client.execute(() => issue._raw.update(updatePayload));
321
+
322
+ const updated = await this.fetchIssue(ref);
323
+ if (!updated) throw new Error(`Failed to fetch updated PRD: ${ref}`);
324
+ return this.toPrd(updated);
325
+ }
326
+
327
+ /**
328
+ * Build label IDs for a PRD status update.
329
+ * Keeps existing non-status labels, adds new status label if needed.
330
+ * @deprecated Use buildPrdLabelIdsWithTag for updates that may include tag changes
331
+ */
332
+ private async buildPrdLabelIds(
333
+ currentLabels: string[],
334
+ newStatus: import("../types.js").PrdStatus,
335
+ ): Promise<string[]> {
336
+ const statusLabels = getAllStatusLabels();
337
+ const newStatusLabel = getStatusLabelForPrdStatus(newStatus);
338
+
339
+ const labelsToKeep = currentLabels.filter((l) => !statusLabels.includes(l));
340
+ if (newStatusLabel) {
341
+ labelsToKeep.push(newStatusLabel);
342
+ }
343
+
344
+ const labelIds: string[] = [];
345
+ for (const labelName of labelsToKeep) {
346
+ const id = await this.getOrCreateLabel(labelName);
347
+ labelIds.push(id);
348
+ }
349
+ return labelIds;
350
+ }
351
+
352
+ /**
353
+ * Build label IDs for a PRD update, handling both status and tag changes.
354
+ * Filters out status labels and milestone labels, then adds the appropriate ones back.
355
+ */
356
+ private async buildPrdLabelIdsWithTag(
357
+ currentLabels: string[],
358
+ newStatus: import("../types.js").PrdStatus,
359
+ newTag: string | null | undefined,
360
+ ): Promise<string[]> {
361
+ const statusLabels = getAllStatusLabels();
362
+ const newStatusLabel = getStatusLabelForPrdStatus(newStatus);
363
+
364
+ // Filter out status labels AND existing milestone labels
365
+ const labelsToKeep = currentLabels.filter(
366
+ (l) =>
367
+ !statusLabels.includes(l) && !l.startsWith(FLUX_MILESTONE_LABEL_PREFIX),
368
+ );
369
+
370
+ // Add status label if needed
371
+ if (newStatusLabel) {
372
+ labelsToKeep.push(newStatusLabel);
373
+ }
374
+
375
+ // Add new tag label if provided (not null/undefined and not empty string)
376
+ if (newTag) {
377
+ labelsToKeep.push(getMilestoneLabel(newTag));
378
+ }
379
+
380
+ // Convert to IDs
381
+ const labelIds: string[] = [];
382
+ for (const labelName of labelsToKeep) {
383
+ const id = await this.getOrCreateLabel(labelName);
384
+ labelIds.push(id);
385
+ }
386
+ return labelIds;
387
+ }
388
+
389
+ /**
390
+ * Get or create a label by name.
391
+ */
392
+ private async getOrCreateLabel(labelName: string): Promise<string> {
393
+ try {
394
+ return await this.getLabelId(labelName);
395
+ } catch {
396
+ const result = await this.client.execute<any>(() =>
397
+ (this.client.client as any).createIssueLabel({
398
+ name: labelName,
399
+ teamId: this.config.teamId,
400
+ }),
401
+ );
402
+ // Linear SDK returns _issueLabel (with underscore prefix)
403
+ const labelId = result?._issueLabel?.id ?? result?.issueLabel?.id;
404
+ if (!labelId || typeof labelId !== "string") {
405
+ throw new Error(
406
+ `Failed to create label: ${labelName}. Result: ${JSON.stringify(result)}`,
407
+ );
408
+ }
409
+ return labelId;
410
+ }
411
+ }
412
+
413
+ async getPrd(ref: string): Promise<Prd | null> {
414
+ const issue = await this.fetchIssue(ref);
415
+ if (!issue || !this.isPrd(issue)) return null;
416
+ return this.toPrd(issue);
417
+ }
418
+
419
+ async listPrds(
420
+ filters?: PrdFilters,
421
+ pagination?: PaginationOptions,
422
+ ): Promise<PaginatedResult<Prd>> {
423
+ const limit = pagination?.limit ?? 50;
424
+ const offset = pagination?.offset ?? 0;
425
+
426
+ const linearFilter: any = {
427
+ project: { id: { eq: this.config.projectId } },
428
+ };
429
+
430
+ // Build label filter - always include prd label, optionally include tag label
431
+ if (filters?.tag) {
432
+ linearFilter.labels = {
433
+ and: [
434
+ { name: { eq: this.config.defaultLabels.prd } },
435
+ { name: { eq: getMilestoneLabel(filters.tag) } },
436
+ ],
437
+ };
438
+ } else {
439
+ linearFilter.labels = { name: { eq: this.config.defaultLabels.prd } };
440
+ }
441
+
442
+ if (filters?.status) {
443
+ linearFilter.state = {
444
+ name: { eq: toLinearPrdIssueState(filters.status) },
445
+ };
446
+ }
447
+
448
+ const issues = await this.fetchIssues(linearFilter, limit + offset);
449
+ const prdIssues = issues.filter((i) => this.isPrd(i));
450
+ const paginated = prdIssues.slice(offset, offset + limit);
451
+
452
+ return {
453
+ items: paginated.map((i) => this.toPrd(i)),
454
+ total: prdIssues.length,
455
+ limit,
456
+ offset,
457
+ hasMore: offset + limit < prdIssues.length,
458
+ };
459
+ }
460
+
461
+ async deletePrd(
462
+ ref: string,
463
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
464
+ const issue = await this.fetchIssue(ref);
465
+ if (!issue) throw new Error(`PRD not found: ${ref}`);
466
+
467
+ const childrenResult = await this.client.execute<any>(() =>
468
+ issue._raw.children(),
469
+ );
470
+ const children = childrenResult.nodes || [];
471
+
472
+ let epicCount = 0;
473
+ let taskCount = 0;
474
+
475
+ for (const child of children) {
476
+ const hydratedChild = await this.hydrateIssue(child);
477
+ if (this.isEpic(hydratedChild)) {
478
+ const epicChildren = await this.client.execute<any>(() =>
479
+ child.children(),
480
+ );
481
+ for (const task of epicChildren.nodes || []) {
482
+ await this.client.execute<any>(() => task.archive());
483
+ taskCount++;
484
+ }
485
+ await this.client.execute<any>(() => child.archive());
486
+ epicCount++;
487
+ }
488
+ }
489
+
490
+ await this.client.execute<any>(() => issue._raw.archive());
491
+
492
+ return {
493
+ deleted: ref,
494
+ cascade: {
495
+ epics: epicCount,
496
+ tasks: taskCount,
497
+ criteria: 0,
498
+ dependencies: 0,
499
+ },
500
+ };
501
+ }
502
+
503
+ // -------------------------------------------------------------------------
504
+ // Epic Operations
505
+ // -------------------------------------------------------------------------
506
+
507
+ async createEpic(input: CreateEpicInput): Promise<Epic> {
508
+ const prdIssue = await this.fetchIssue(input.prdRef);
509
+ if (!prdIssue) throw new Error(`PRD not found: ${input.prdRef}`);
510
+ if (!this.isPrd(prdIssue))
511
+ throw new Error(`Issue ${input.prdRef} is not a PRD`);
512
+
513
+ const epicLabelId = await this.getLabelId(this.config.defaultLabels.epic);
514
+ const todoStateId = await this.getStateId("Todo");
515
+ const formattedDescription = formatDescription({
516
+ description: input.description,
517
+ acceptanceCriteria: input.acceptanceCriteria,
518
+ });
519
+
520
+ const createResult = await this.client.execute(() =>
521
+ this.client.client.createIssue({
522
+ title: input.title,
523
+ description: formattedDescription,
524
+ teamId: this.config.teamId,
525
+ projectId: this.config.projectId,
526
+ parentId: prdIssue.id,
527
+ labelIds: [epicLabelId],
528
+ stateId: todoStateId,
529
+ }),
530
+ );
531
+
532
+ const rawIssue = await this.client.execute<any>(
533
+ () => (createResult as any).issue,
534
+ );
535
+ if (!rawIssue) throw new Error("Failed to create Epic issue");
536
+
537
+ const issue = await this.hydrateIssue(rawIssue);
538
+ return this.toEpic(issue);
539
+ }
540
+
541
+ async updateEpic(ref: string, input: UpdateEpicInput): Promise<Epic> {
542
+ const issue = await this.fetchIssue(ref);
543
+ if (!issue) throw new Error(`Epic not found: ${ref}`);
544
+
545
+ const updatePayload: any = {};
546
+ if (input.title !== undefined) updatePayload.title = input.title;
547
+ if (input.description !== undefined)
548
+ updatePayload.description = input.description;
549
+ if (input.status !== undefined) {
550
+ updatePayload.stateId = await this.getStateId(
551
+ toLinearEpicState(input.status),
552
+ );
553
+ }
554
+
555
+ await this.client.execute(() => issue._raw.update(updatePayload));
556
+ // Re-fetch to get the updated issue with all fields
557
+ const updated = await this.fetchIssue(ref);
558
+ if (!updated) {
559
+ throw new Error(`Failed to fetch updated Epic: ${ref}`);
560
+ }
561
+ return this.toEpic(updated);
562
+ }
563
+
564
+ async getEpic(ref: string): Promise<Epic | null> {
565
+ const issue = await this.fetchIssue(ref);
566
+ if (!issue || !this.isEpic(issue)) return null;
567
+ return this.toEpic(issue);
568
+ }
569
+
570
+ async listEpics(
571
+ filters?: EpicFilters,
572
+ pagination?: PaginationOptions,
573
+ ): Promise<PaginatedResult<Epic>> {
574
+ const limit = pagination?.limit ?? 50;
575
+ const offset = pagination?.offset ?? 0;
576
+
577
+ const linearFilter: any = {
578
+ labels: { name: { eq: this.config.defaultLabels.epic } },
579
+ project: { id: { eq: this.config.projectId } },
580
+ };
581
+
582
+ if (filters?.prdRef) {
583
+ const prdIssue = await this.fetchIssue(filters.prdRef);
584
+ if (prdIssue) {
585
+ linearFilter.parent = { id: { eq: prdIssue.id } };
586
+ }
587
+ }
588
+
589
+ if (filters?.status) {
590
+ linearFilter.state = { name: { eq: toLinearEpicState(filters.status) } };
591
+ }
592
+
593
+ const issues = await this.fetchIssues(linearFilter, limit + offset);
594
+ const epicIssues = issues.filter((i) => this.isEpic(i));
595
+ const paginated = epicIssues.slice(offset, offset + limit);
596
+
597
+ return {
598
+ items: paginated.map((i) => this.toEpic(i)),
599
+ total: epicIssues.length,
600
+ limit,
601
+ offset,
602
+ hasMore: offset + limit < epicIssues.length,
603
+ };
604
+ }
605
+
606
+ async deleteEpic(
607
+ ref: string,
608
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
609
+ const issue = await this.fetchIssue(ref);
610
+ if (!issue) throw new Error(`Epic not found: ${ref}`);
611
+
612
+ const childrenResult = await this.client.execute<any>(() =>
613
+ issue._raw.children(),
614
+ );
615
+ const children = childrenResult.nodes || [];
616
+
617
+ for (const child of children) {
618
+ await this.client.execute<any>(() => child.archive());
619
+ }
620
+
621
+ await this.client.execute<any>(() => issue._raw.archive());
622
+
623
+ return {
624
+ deleted: ref,
625
+ cascade: {
626
+ epics: 0,
627
+ tasks: children.length,
628
+ criteria: 0,
629
+ dependencies: 0,
630
+ },
631
+ };
632
+ }
633
+
634
+ // -------------------------------------------------------------------------
635
+ // Task Operations
636
+ // -------------------------------------------------------------------------
637
+
638
+ async createTask(input: CreateTaskInput): Promise<Task> {
639
+ const epicIssue = await this.fetchIssue(input.epicRef);
640
+ if (!epicIssue) throw new Error(`Epic not found: ${input.epicRef}`);
641
+
642
+ const taskLabelId = await this.getLabelId(this.config.defaultLabels.task);
643
+ const todoStateId = await this.getStateId("Todo");
644
+ const formattedDescription = formatDescription({
645
+ description: input.description,
646
+ acceptanceCriteria: input.acceptanceCriteria,
647
+ });
648
+
649
+ const createResult = await this.client.execute(() =>
650
+ this.client.client.createIssue({
651
+ title: input.title,
652
+ description: formattedDescription,
653
+ teamId: this.config.teamId,
654
+ projectId: this.config.projectId,
655
+ parentId: epicIssue.id,
656
+ labelIds: [taskLabelId],
657
+ stateId: todoStateId,
658
+ priority: toLinearPriority(input.priority ?? "MEDIUM"),
659
+ }),
660
+ );
661
+
662
+ const rawIssue = await this.client.execute<any>(
663
+ () => (createResult as any).issue,
664
+ );
665
+ if (!rawIssue) throw new Error("Failed to create Task issue");
666
+
667
+ const issue = await this.hydrateIssue(rawIssue);
668
+ return this.toTask(issue);
669
+ }
670
+
671
+ async updateTask(ref: string, input: UpdateTaskInput): Promise<Task> {
672
+ const issue = await this.fetchIssue(ref);
673
+ if (!issue) throw new Error(`Task not found: ${ref}`);
674
+
675
+ const updatePayload: any = {};
676
+ if (input.title !== undefined) updatePayload.title = input.title;
677
+ if (input.description !== undefined)
678
+ updatePayload.description = input.description;
679
+ if (input.priority !== undefined)
680
+ updatePayload.priority = toLinearPriority(input.priority);
681
+ if (input.status !== undefined) {
682
+ const stateName = toLinearTaskState(input.status);
683
+ const team = await this.client.execute(() =>
684
+ this.client.client.team(this.config.teamId),
685
+ );
686
+ const states = await this.client.execute(() => team.states());
687
+ const targetState = states.nodes.find((s: any) => s.name === stateName);
688
+ if (targetState) updatePayload.stateId = targetState.id;
689
+ }
690
+
691
+ await this.client.execute(() => issue._raw.update(updatePayload));
692
+ // Re-fetch to get the updated issue with all fields
693
+ const updated = await this.fetchIssue(ref);
694
+ if (!updated) {
695
+ throw new Error(`Failed to fetch updated Task: ${ref}`);
696
+ }
697
+ return this.toTask(updated);
698
+ }
699
+
700
+ async getTask(ref: string): Promise<Task | null> {
701
+ const issue = await this.fetchIssue(ref);
702
+ if (!issue) return null;
703
+ // Task must have task label or have a parent
704
+ if (!this.isTask(issue) && !issue.parentIdentifier) return null;
705
+ return this.toTask(issue);
706
+ }
707
+
708
+ async listTasks(
709
+ filters?: TaskFilters,
710
+ pagination?: PaginationOptions,
711
+ ): Promise<PaginatedResult<Task>> {
712
+ const limit = pagination?.limit ?? 50;
713
+ const offset = pagination?.offset ?? 0;
714
+
715
+ const linearFilter: any = {
716
+ labels: { name: { eq: this.config.defaultLabels.task } },
717
+ project: { id: { eq: this.config.projectId } },
718
+ };
719
+
720
+ if (filters?.epicRef) {
721
+ const epicIssue = await this.fetchIssue(filters.epicRef);
722
+ if (!epicIssue) throw new Error(`Epic not found: ${filters.epicRef}`);
723
+ linearFilter.parent = { id: { eq: epicIssue.id } };
724
+ }
725
+
726
+ const result = await this.client.execute(() =>
727
+ this.client.client.issues({
728
+ filter: linearFilter,
729
+ first: limit + offset,
730
+ }),
731
+ );
732
+
733
+ const issues = await Promise.all(
734
+ result.nodes.map((i: any) => this.hydrateIssue(i)),
735
+ );
736
+ const paginated = issues.slice(offset, offset + limit);
737
+
738
+ return {
739
+ items: paginated.map((i) => this.toTask(i)),
740
+ total: issues.length,
741
+ limit,
742
+ offset,
743
+ hasMore: result.pageInfo?.hasNextPage ?? false,
744
+ };
745
+ }
746
+
747
+ async deleteTask(
748
+ ref: string,
749
+ ): Promise<{ deleted: string; cascade: CascadeResult }> {
750
+ const issue = await this.fetchIssue(ref);
751
+ if (!issue) throw new Error(`Task not found: ${ref}`);
752
+
753
+ await this.client.execute(() => issue._raw.archive());
754
+
755
+ return {
756
+ deleted: ref,
757
+ cascade: { epics: 0, tasks: 0, criteria: 0, dependencies: 0 },
758
+ };
759
+ }
760
+
761
+ // -------------------------------------------------------------------------
762
+ // Acceptance Criteria Operations
763
+ // -------------------------------------------------------------------------
764
+
765
+ async addCriterion(input: AddCriterionInput): Promise<AcceptanceCriterion> {
766
+ const issue = await this.fetchIssue(input.parentRef);
767
+ if (!issue) throw new Error(`Entity not found: ${input.parentRef}`);
768
+
769
+ const { addCriterionToDescription, generateCriteriaId } = await import(
770
+ "./helpers/index.js"
771
+ );
772
+ const newDescription = addCriterionToDescription(
773
+ issue.description,
774
+ input.criteria,
775
+ );
776
+
777
+ await this.client.execute<any>(() =>
778
+ issue._raw.update({ description: newDescription }),
779
+ );
780
+
781
+ return {
782
+ id: generateCriteriaId(input.criteria),
783
+ parentType: "epic" as const,
784
+ parentId: input.parentRef,
785
+ criteria: input.criteria,
786
+ isMet: false,
787
+ createdAt: new Date().toISOString(),
788
+ };
789
+ }
790
+
791
+ async markCriterionMet(criterionId: string): Promise<AcceptanceCriterion> {
792
+ const { parseCriteriaFromDescription, updateCriterionInDescription } =
793
+ await import("./helpers/index.js");
794
+
795
+ const issues = await this.fetchIssues(
796
+ { project: { id: { eq: this.config.projectId } } },
797
+ 100,
798
+ );
799
+
800
+ for (const issue of issues) {
801
+ const criteria = parseCriteriaFromDescription(issue.description);
802
+ const criterion = criteria.find((c) => c.id === criterionId);
803
+
804
+ if (criterion) {
805
+ const newDescription = updateCriterionInDescription(
806
+ issue.description || "",
807
+ criterionId,
808
+ true,
809
+ );
810
+
811
+ await this.client.execute<any>(() =>
812
+ issue._raw.update({ description: newDescription }),
813
+ );
814
+
815
+ return {
816
+ id: criterionId,
817
+ parentType: "epic" as const,
818
+ parentId: issue.identifier,
819
+ criteria: criterion.text,
820
+ isMet: true,
821
+ createdAt: new Date().toISOString(),
822
+ };
823
+ }
824
+ }
825
+
826
+ throw new Error(`Criterion not found: ${criterionId}`);
827
+ }
828
+
829
+ async getCriteria(parentRef: string): Promise<AcceptanceCriterion[]> {
830
+ const issue = await this.fetchIssue(parentRef);
831
+ if (!issue) throw new Error(`Entity not found: ${parentRef}`);
832
+
833
+ const { parseCriteriaFromDescription } = await import("./helpers/index.js");
834
+ const parsed = parseCriteriaFromDescription(issue.description);
835
+
836
+ return parsed.map((c) => ({
837
+ id: c.id,
838
+ parentType: "epic" as const,
839
+ parentId: parentRef,
840
+ criteria: c.text,
841
+ isMet: c.isMet,
842
+ createdAt: new Date().toISOString(),
843
+ }));
844
+ }
845
+
846
+ // -------------------------------------------------------------------------
847
+ // Dependency Operations
848
+ // -------------------------------------------------------------------------
849
+
850
+ async addDependency(ref: string, dependsOnRef: string): Promise<void> {
851
+ if (ref === dependsOnRef) {
852
+ throw new Error("Entity cannot depend on itself");
853
+ }
854
+
855
+ const [blockedIssue, blockerIssue] = await Promise.all([
856
+ this.fetchIssue(ref),
857
+ this.fetchIssue(dependsOnRef),
858
+ ]);
859
+
860
+ if (!blockedIssue) throw new Error(`Issue not found: ${ref}`);
861
+ if (!blockerIssue) throw new Error(`Issue not found: ${dependsOnRef}`);
862
+
863
+ const blockedType = this.getEntityType(blockedIssue);
864
+ const blockerType = this.getEntityType(blockerIssue);
865
+
866
+ if (blockedType !== blockerType) {
867
+ throw new Error(
868
+ `Cannot create dependency between different entity types (${blockedType} and ${blockerType})`,
869
+ );
870
+ }
871
+
872
+ await this.client.execute<any>(() =>
873
+ this.client.client.createIssueRelation({
874
+ issueId: blockerIssue.id,
875
+ relatedIssueId: blockedIssue.id,
876
+ type: "blocks" as any,
877
+ }),
878
+ );
879
+ }
880
+
881
+ async removeDependency(ref: string, dependsOnRef: string): Promise<void> {
882
+ const [blockedIssue, blockerIssue] = await Promise.all([
883
+ this.fetchIssue(ref),
884
+ this.fetchIssue(dependsOnRef),
885
+ ]);
886
+
887
+ if (!blockedIssue) throw new Error(`Issue not found: ${ref}`);
888
+ if (!blockerIssue) throw new Error(`Issue not found: ${dependsOnRef}`);
889
+
890
+ // Use inverseRelations to find relations where blockedIssue is the target
891
+ const relations = await this.client.execute<any>(() =>
892
+ blockedIssue._raw.inverseRelations(),
893
+ );
894
+
895
+ // Find the relation to delete - need to await rel.issue since it's lazy-loaded
896
+ let relationToDelete: any = null;
897
+ for (const rel of relations.nodes) {
898
+ if (rel.type === "blocks") {
899
+ const relIssue = await rel.issue;
900
+ if (relIssue?.id === blockerIssue.id) {
901
+ relationToDelete = rel;
902
+ break;
903
+ }
904
+ }
905
+ }
906
+
907
+ if (!relationToDelete) {
908
+ throw new Error(
909
+ `Dependency not found between ${ref} and ${dependsOnRef}`,
910
+ );
911
+ }
912
+
913
+ await this.client.execute<any>(() => relationToDelete.delete());
914
+ }
915
+
916
+ async getDependencies(ref: string): Promise<string[]> {
917
+ const issue = await this.fetchIssue(ref);
918
+ if (!issue) throw new Error(`Issue not found: ${ref}`);
919
+
920
+ // Use inverseRelations to get relations where this issue is the target (relatedIssueId)
921
+ // This finds issues that block this one
922
+ const relations = await this.client.execute<any>(() =>
923
+ issue._raw.inverseRelations(),
924
+ );
925
+
926
+ const blockingRefs: string[] = [];
927
+ for (const rel of relations.nodes) {
928
+ if (rel.type === "blocks") {
929
+ // Linear SDK returns lazy-loaded promises for related objects
930
+ const blockerIssue = await rel.issue;
931
+ if (blockerIssue?.identifier) {
932
+ blockingRefs.push(blockerIssue.identifier);
933
+ }
934
+ }
935
+ }
936
+
937
+ return blockingRefs;
938
+ }
939
+
940
+ private getEntityType(issue: HydratedIssue): "prd" | "epic" | "task" {
941
+ if (this.isPrd(issue)) return "prd";
942
+ if (this.isEpic(issue)) return "epic";
943
+ if (this.isTask(issue)) return "task";
944
+ return issue.parentIdentifier ? "task" : "epic";
945
+ }
946
+
947
+ // -------------------------------------------------------------------------
948
+ // Document Operations
949
+ // -------------------------------------------------------------------------
950
+
951
+ async saveDocument(doc: Document): Promise<Document> {
952
+ const issue = await this.fetchIssue(doc.prdRef);
953
+ if (!issue) throw new Error(`PRD ${doc.prdRef} not found`);
954
+
955
+ const existingAttachments = await this.client.execute<any>(() =>
956
+ issue._raw.attachments(),
957
+ );
958
+ const existingAttachment = existingAttachments.nodes.find(
959
+ (att: any) => att.title === doc.filename,
960
+ );
961
+
962
+ if (existingAttachment) {
963
+ await this.client.execute<any>(() => existingAttachment.archive());
964
+ }
965
+
966
+ const contentUrl = `data:text/markdown;base64,${Buffer.from(doc.content).toString("base64")}`;
967
+
968
+ const attachResult = await this.client.execute<any>(() =>
969
+ (this.client.client as any).attachmentCreate({
970
+ title: doc.filename,
971
+ url: contentUrl,
972
+ issueId: issue.id,
973
+ }),
974
+ );
975
+
976
+ if (!attachResult.success || !attachResult.attachment) {
977
+ throw new Error(`Failed to create attachment for ${doc.filename}`);
978
+ }
979
+
980
+ return {
981
+ prdRef: doc.prdRef,
982
+ filename: doc.filename,
983
+ content: doc.content,
984
+ url: attachResult.attachment.url,
985
+ };
986
+ }
987
+
988
+ async getDocuments(prdRef: string): Promise<Document[]> {
989
+ const issue = await this.fetchIssue(prdRef);
990
+ if (!issue) throw new Error(`PRD ${prdRef} not found`);
991
+
992
+ const attachments = await this.client.execute<any>(() =>
993
+ issue._raw.attachments(),
994
+ );
995
+ const documents: Document[] = [];
996
+
997
+ for (const attachment of attachments.nodes) {
998
+ try {
999
+ const url = attachment.url || "";
1000
+ if (url.startsWith("data:text/markdown;base64,")) {
1001
+ const base64Content = url.substring(
1002
+ "data:text/markdown;base64,".length,
1003
+ );
1004
+ const content = Buffer.from(base64Content, "base64").toString(
1005
+ "utf-8",
1006
+ );
1007
+ documents.push({
1008
+ prdRef,
1009
+ filename: attachment.title,
1010
+ content,
1011
+ url: attachment.url,
1012
+ });
1013
+ }
1014
+ } catch {}
1015
+ }
1016
+
1017
+ return documents;
1018
+ }
1019
+
1020
+ async deleteDocument(prdRef: string, filename: string): Promise<void> {
1021
+ const issue = await this.fetchIssue(prdRef);
1022
+ if (!issue) throw new Error(`PRD ${prdRef} not found`);
1023
+
1024
+ const attachments = await this.client.execute<any>(() =>
1025
+ issue._raw.attachments(),
1026
+ );
1027
+ const attachment = attachments.nodes.find(
1028
+ (att: any) => att.title === filename,
1029
+ );
1030
+
1031
+ if (!attachment) throw new Error(`Document ${filename} not found`);
1032
+
1033
+ await this.client.execute<any>(() => attachment.archive());
1034
+ }
1035
+
1036
+ // -------------------------------------------------------------------------
1037
+ // Stats
1038
+ // -------------------------------------------------------------------------
1039
+
1040
+ async getStats(): Promise<Stats> {
1041
+ const issues = await this.fetchIssues(
1042
+ { project: { id: { eq: this.config.projectId } } },
1043
+ 200,
1044
+ );
1045
+
1046
+ const prdStats = {
1047
+ total: 0,
1048
+ draft: 0,
1049
+ pendingReview: 0,
1050
+ reviewed: 0,
1051
+ approved: 0,
1052
+ breakdownReady: 0,
1053
+ completed: 0,
1054
+ archived: 0,
1055
+ };
1056
+ const epicStats = { total: 0, pending: 0, inProgress: 0, completed: 0 };
1057
+ const taskStats = { total: 0, pending: 0, inProgress: 0, completed: 0 };
1058
+
1059
+ for (const issue of issues) {
1060
+ if (this.isPrd(issue)) {
1061
+ prdStats.total++;
1062
+ if (issue.stateName === "Backlog" || issue.stateType === "backlog") {
1063
+ prdStats.draft++;
1064
+ } else if (
1065
+ issue.stateName === "In Progress" ||
1066
+ issue.stateType === "started"
1067
+ ) {
1068
+ prdStats.approved++;
1069
+ } else if (
1070
+ issue.stateName === "Done" ||
1071
+ issue.stateType === "completed"
1072
+ ) {
1073
+ prdStats.completed++;
1074
+ } else if (
1075
+ issue.stateName === "Canceled" ||
1076
+ issue.stateType === "canceled"
1077
+ ) {
1078
+ prdStats.archived++;
1079
+ } else {
1080
+ prdStats.draft++;
1081
+ }
1082
+ } else if (this.isEpic(issue)) {
1083
+ epicStats.total++;
1084
+ if (issue.stateType === "backlog" || issue.stateType === "unstarted") {
1085
+ epicStats.pending++;
1086
+ } else if (issue.stateType === "started") {
1087
+ epicStats.inProgress++;
1088
+ } else if (issue.stateType === "completed") {
1089
+ epicStats.completed++;
1090
+ }
1091
+ } else if (this.isTask(issue)) {
1092
+ taskStats.total++;
1093
+ if (issue.stateType === "backlog" || issue.stateType === "unstarted") {
1094
+ taskStats.pending++;
1095
+ } else if (issue.stateType === "started") {
1096
+ taskStats.inProgress++;
1097
+ } else if (issue.stateType === "completed") {
1098
+ taskStats.completed++;
1099
+ }
1100
+ }
1101
+ }
1102
+
1103
+ return { prds: prdStats, epics: epicStats, tasks: taskStats };
1104
+ }
1105
+
1106
+ // -------------------------------------------------------------------------
1107
+ // URL Generation
1108
+ // -------------------------------------------------------------------------
1109
+
1110
+ async getLinearUrl(ref: string): Promise<{
1111
+ url: string;
1112
+ type: string;
1113
+ name?: string;
1114
+ identifier?: string;
1115
+ }> {
1116
+ const org = await this.client.execute(
1117
+ () => this.client.client.organization,
1118
+ );
1119
+ const workspaceSlug = org.urlKey;
1120
+
1121
+ // Check if it's a project ref (starts with "proj_" or similar)
1122
+ if (ref.startsWith("proj_") || ref.includes("_")) {
1123
+ const project = await this.client.execute(() =>
1124
+ this.client.client.project(ref),
1125
+ );
1126
+ const slug = project.name.toLowerCase().replace(/\s+/g, "-");
1127
+ return {
1128
+ url: `https://linear.app/${workspaceSlug}/project/${slug}-${project.id}`,
1129
+ type: "project",
1130
+ name: project.name,
1131
+ };
1132
+ }
1133
+
1134
+ // It's an issue identifier (ENG-42)
1135
+ return {
1136
+ url: `https://linear.app/${workspaceSlug}/issue/${ref}`,
1137
+ type: "issue",
1138
+ identifier: ref,
1139
+ };
1140
+ }
1141
+ }