@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,169 @@
1
+ /**
2
+ * Linear SDK Client Wrapper
3
+ *
4
+ * Wraps @linear/sdk with automatic retry logic for transient errors.
5
+ * - Rate limit errors (429) trigger exponential backoff
6
+ * - Network errors are retried with backoff
7
+ * - Other errors fail immediately
8
+ */
9
+
10
+ import { LinearClient as LinearSDK } from "@linear/sdk";
11
+ import type { LinearConfig } from "./types.js";
12
+
13
+ /**
14
+ * Custom error class for Linear API errors.
15
+ */
16
+ export class LinearApiError extends Error {
17
+ constructor(
18
+ message: string,
19
+ public readonly code: string,
20
+ public readonly statusCode?: number,
21
+ public readonly originalError?: Error,
22
+ ) {
23
+ super(message);
24
+ this.name = "LinearApiError";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Options for configuring retry behavior.
30
+ */
31
+ interface RetryOptions {
32
+ /** Maximum number of retry attempts (default: 3) */
33
+ maxRetries?: number;
34
+ /** Base delay in milliseconds for exponential backoff (default: 1000) */
35
+ baseDelay?: number;
36
+ }
37
+
38
+ /**
39
+ * LinearClient wraps the Linear SDK with automatic retry logic.
40
+ */
41
+ export class LinearClient {
42
+ private sdk: LinearSDK;
43
+ private maxRetries: number;
44
+ private baseDelay: number;
45
+
46
+ constructor(config: LinearConfig, options?: RetryOptions) {
47
+ this.sdk = new LinearSDK({ apiKey: config.apiKey });
48
+ this.maxRetries = options?.maxRetries ?? 3;
49
+ this.baseDelay = options?.baseDelay ?? 1000;
50
+ }
51
+
52
+ /**
53
+ * Get the underlying SDK for direct access when needed.
54
+ */
55
+ get client(): LinearSDK {
56
+ return this.sdk;
57
+ }
58
+
59
+ /**
60
+ * Execute a request with automatic retry on transient errors.
61
+ * - 429 (rate limit) → exponential backoff with retry
62
+ * - Network errors → retry with backoff
63
+ * - 401 (unauthorized) → throw immediately with UNAUTHORIZED code
64
+ * - Other errors → throw immediately
65
+ *
66
+ * @param operation The async operation to execute
67
+ * @returns Promise resolving to the operation result
68
+ * @throws LinearApiError on failure
69
+ */
70
+ async execute<T>(operation: () => Promise<T>): Promise<T> {
71
+ let lastError: Error | undefined;
72
+
73
+ for (let attempt = 0; attempt <= this.maxRetries; attempt++) {
74
+ try {
75
+ return await operation();
76
+ } catch (error: any) {
77
+ lastError = error;
78
+
79
+ // Check if error is retryable
80
+ const isRateLimited = error.status === 429;
81
+ const isNetworkError = this.isNetworkError(error);
82
+ const isUnauthorized = error.status === 401;
83
+
84
+ // Throw immediately for unauthorized errors
85
+ if (isUnauthorized) {
86
+ throw new LinearApiError(
87
+ "Unauthorized: Invalid API key",
88
+ "UNAUTHORIZED",
89
+ 401,
90
+ error,
91
+ );
92
+ }
93
+
94
+ // Throw immediately for non-retryable errors
95
+ if (!isRateLimited && !isNetworkError) {
96
+ throw new LinearApiError(
97
+ error.message || "Linear API error",
98
+ "API_ERROR",
99
+ error.status,
100
+ error,
101
+ );
102
+ }
103
+
104
+ // If we've exhausted retries, throw with appropriate error code
105
+ if (attempt === this.maxRetries) {
106
+ if (isRateLimited) {
107
+ throw new LinearApiError(
108
+ `Rate limited after ${this.maxRetries} retries`,
109
+ "RATE_LIMITED",
110
+ 429,
111
+ error,
112
+ );
113
+ }
114
+ throw new LinearApiError(
115
+ `Network error after ${this.maxRetries} retries: ${error.message}`,
116
+ "NETWORK_ERROR",
117
+ undefined,
118
+ error,
119
+ );
120
+ }
121
+
122
+ // Calculate delay with exponential backoff and jitter
123
+ const delay = this.calculateDelay(attempt);
124
+ await this.sleep(delay);
125
+ }
126
+ }
127
+
128
+ // This should never be reached, but TypeScript needs it
129
+ throw new LinearApiError(
130
+ "Unexpected error: max retries exceeded",
131
+ "API_ERROR",
132
+ undefined,
133
+ lastError,
134
+ );
135
+ }
136
+
137
+ /**
138
+ * Check if an error is a network error.
139
+ */
140
+ private isNetworkError(error: any): boolean {
141
+ // Common network error codes
142
+ const networkErrorCodes = [
143
+ "ECONNRESET",
144
+ "ECONNREFUSED",
145
+ "ETIMEDOUT",
146
+ "ENOTFOUND",
147
+ "ENETUNREACH",
148
+ ];
149
+
150
+ return networkErrorCodes.includes(error.code);
151
+ }
152
+
153
+ /**
154
+ * Calculate delay with exponential backoff and jitter.
155
+ * Formula: baseDelay * 2^attempt with ±10% randomization
156
+ */
157
+ private calculateDelay(attempt: number): number {
158
+ const exponentialDelay = this.baseDelay * 2 ** attempt;
159
+ const jitter = exponentialDelay * 0.1 * (Math.random() * 2 - 1); // ±10%
160
+ return Math.floor(exponentialDelay + jitter);
161
+ }
162
+
163
+ /**
164
+ * Sleep for the specified duration.
165
+ */
166
+ private sleep(ms: number): Promise<void> {
167
+ return new Promise((resolve) => setTimeout(resolve, ms));
168
+ }
169
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Linear Configuration Loader and Validator
3
+ *
4
+ * Manages Linear configuration stored in .flux/linear-config.json
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
+ import { config } from "../../config.js";
9
+ import type { LinearConfig } from "./types.js";
10
+
11
+ const LINEAR_CONFIG_FILE = "linear-config.json";
12
+
13
+ /**
14
+ * Get the path to the Linear config file.
15
+ */
16
+ function getLinearConfigPath(): string {
17
+ return `${config.fluxPath}/${LINEAR_CONFIG_FILE}`;
18
+ }
19
+
20
+ /**
21
+ * Check if Linear config exists.
22
+ */
23
+ export function linearConfigExists(): boolean {
24
+ return existsSync(getLinearConfigPath());
25
+ }
26
+
27
+ /**
28
+ * Validate LinearConfig object.
29
+ * @throws Error with clear message if invalid
30
+ */
31
+ export function validateLinearConfig(input: unknown): LinearConfig {
32
+ // Check if input is an object
33
+ if (typeof input !== "object" || input === null || Array.isArray(input)) {
34
+ throw new Error("Invalid Linear config: must be an object");
35
+ }
36
+
37
+ const config = input as Record<string, unknown>;
38
+
39
+ // Validate apiKey
40
+ if (!config.apiKey) {
41
+ throw new Error("Invalid Linear config: apiKey is required");
42
+ }
43
+ if (typeof config.apiKey !== "string") {
44
+ throw new Error("Invalid Linear config: apiKey must be a string");
45
+ }
46
+
47
+ // Validate teamId
48
+ if (!config.teamId) {
49
+ throw new Error("Invalid Linear config: teamId is required");
50
+ }
51
+ if (typeof config.teamId !== "string") {
52
+ throw new Error("Invalid Linear config: teamId must be a string");
53
+ }
54
+
55
+ // Validate projectId
56
+ if (!config.projectId) {
57
+ throw new Error("Invalid Linear config: projectId is required");
58
+ }
59
+ if (typeof config.projectId !== "string") {
60
+ throw new Error("Invalid Linear config: projectId must be a string");
61
+ }
62
+
63
+ // Handle defaultLabels - use defaults if not provided
64
+ const defaultLabels = {
65
+ prd: "prd",
66
+ epic: "epic",
67
+ task: "task",
68
+ };
69
+
70
+ if (config.defaultLabels) {
71
+ if (
72
+ typeof config.defaultLabels !== "object" ||
73
+ config.defaultLabels === null ||
74
+ Array.isArray(config.defaultLabels)
75
+ ) {
76
+ throw new Error("Invalid Linear config: defaultLabels must be an object");
77
+ }
78
+
79
+ const labels = config.defaultLabels as Record<string, unknown>;
80
+
81
+ // Merge with defaults
82
+ if (labels.prd !== undefined) {
83
+ if (typeof labels.prd !== "string") {
84
+ throw new Error(
85
+ "Invalid Linear config: defaultLabels.prd must be a string",
86
+ );
87
+ }
88
+ defaultLabels.prd = labels.prd;
89
+ }
90
+
91
+ if (labels.epic !== undefined) {
92
+ if (typeof labels.epic !== "string") {
93
+ throw new Error(
94
+ "Invalid Linear config: defaultLabels.epic must be a string",
95
+ );
96
+ }
97
+ defaultLabels.epic = labels.epic;
98
+ }
99
+
100
+ if (labels.task !== undefined) {
101
+ if (typeof labels.task !== "string") {
102
+ throw new Error(
103
+ "Invalid Linear config: defaultLabels.task must be a string",
104
+ );
105
+ }
106
+ defaultLabels.task = labels.task;
107
+ }
108
+ }
109
+
110
+ return {
111
+ apiKey: config.apiKey,
112
+ teamId: config.teamId,
113
+ projectId: config.projectId,
114
+ defaultLabels,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Load Linear config from .flux/linear-config.json.
120
+ * @throws Error if config file doesn't exist
121
+ * @throws Error if config is invalid (missing required fields)
122
+ */
123
+ export function loadLinearConfig(): LinearConfig {
124
+ const configPath = getLinearConfigPath();
125
+
126
+ if (!existsSync(configPath)) {
127
+ throw new Error("Linear config not found. Run configure_linear first.");
128
+ }
129
+
130
+ const raw = readFileSync(configPath, "utf-8");
131
+ const parsed = JSON.parse(raw);
132
+
133
+ return validateLinearConfig(parsed);
134
+ }
135
+
136
+ /**
137
+ * Save Linear config to .flux/linear-config.json.
138
+ * Used by configure_linear MCP tool.
139
+ */
140
+ export function saveLinearConfig(linearConfig: LinearConfig): void {
141
+ // Validate before saving
142
+ const validated = validateLinearConfig(linearConfig);
143
+
144
+ // Ensure .flux directory exists
145
+ const fluxPath = config.fluxPath;
146
+ if (!existsSync(fluxPath)) {
147
+ mkdirSync(fluxPath, { recursive: true });
148
+ }
149
+
150
+ const configPath = getLinearConfigPath();
151
+ writeFileSync(configPath, JSON.stringify(validated, null, 2), "utf-8");
152
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Criteria Parser for Linear Adapter
3
+ *
4
+ * Parses acceptance criteria from issue descriptions and provides
5
+ * efficient operations for reading and updating criteria.
6
+ */
7
+
8
+ import { createHash } from "node:crypto";
9
+
10
+ export interface ParsedCriterion {
11
+ id: string; // Hash-based stable ID
12
+ text: string; // Criterion text
13
+ isMet: boolean; // Whether checkbox is checked
14
+ lineIndex: number; // Line number in description (for updates)
15
+ }
16
+
17
+ /**
18
+ * Generate a stable ID for a criterion based on its text content.
19
+ * Uses first 8 chars of SHA-256 hash for brevity while maintaining uniqueness.
20
+ */
21
+ export function generateCriteriaId(text: string): string {
22
+ const hash = createHash("sha256").update(text.trim()).digest("hex");
23
+ return `ac_${hash.substring(0, 8)}`;
24
+ }
25
+
26
+ /**
27
+ * Parse acceptance criteria from a markdown description.
28
+ * Looks for checkbox items: `- [ ] text` or `- [x] text`
29
+ *
30
+ * Only parses items under "Acceptance Criteria" heading if present,
31
+ * otherwise parses all checkbox items in the document.
32
+ */
33
+ export function parseCriteriaFromDescription(
34
+ description: string | undefined,
35
+ ): ParsedCriterion[] {
36
+ if (!description) {
37
+ return [];
38
+ }
39
+
40
+ const lines = description.split("\n");
41
+
42
+ // First pass: check if AC section exists
43
+ let acSectionStart = -1;
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const trimmedLine = lines[i].trim().toLowerCase();
46
+ if (
47
+ trimmedLine.includes("acceptance criteria") &&
48
+ (trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
49
+ ) {
50
+ acSectionStart = i;
51
+ break;
52
+ }
53
+ }
54
+
55
+ // Second pass: parse checkboxes
56
+ const criteria: ParsedCriterion[] = [];
57
+ let inACSection = acSectionStart === -1; // If no AC section, parse all
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i];
61
+ const trimmedLine = line.trim().toLowerCase();
62
+
63
+ // Check for AC section header
64
+ if (i === acSectionStart) {
65
+ inACSection = true;
66
+ continue;
67
+ }
68
+
69
+ // If we're in AC section, check for next section header
70
+ if (acSectionStart !== -1 && i > acSectionStart && inACSection) {
71
+ if (
72
+ trimmedLine.startsWith("#") ||
73
+ (trimmedLine.startsWith("**") && trimmedLine.endsWith("**"))
74
+ ) {
75
+ break;
76
+ }
77
+ }
78
+
79
+ // Parse checkbox items only when in correct section
80
+ if (inACSection) {
81
+ const checkboxMatch = line.match(/^[\s]*[-*]\s*\[([ xX])\]\s*(.+)$/);
82
+ if (checkboxMatch) {
83
+ const isChecked = checkboxMatch[1].toLowerCase() === "x";
84
+ const text = checkboxMatch[2].trim();
85
+
86
+ criteria.push({
87
+ id: generateCriteriaId(text),
88
+ text,
89
+ isMet: isChecked,
90
+ lineIndex: i,
91
+ });
92
+ }
93
+ }
94
+ }
95
+
96
+ return criteria;
97
+ }
98
+
99
+ /**
100
+ * Update a specific criterion's checked state in the description.
101
+ * Returns the updated description string.
102
+ */
103
+ export function updateCriterionInDescription(
104
+ description: string,
105
+ criteriaId: string,
106
+ isMet: boolean,
107
+ ): string {
108
+ const lines = description.split("\n");
109
+ const criteria = parseCriteriaFromDescription(description);
110
+
111
+ const criterion = criteria.find((c) => c.id === criteriaId);
112
+ if (!criterion) {
113
+ throw new Error(`Criterion not found: ${criteriaId}`);
114
+ }
115
+
116
+ // Update the specific line
117
+ const line = lines[criterion.lineIndex];
118
+ const newCheckbox = isMet ? "[x]" : "[ ]";
119
+ const updatedLine = line.replace(/\[([ xX])\]/, newCheckbox);
120
+ lines[criterion.lineIndex] = updatedLine;
121
+
122
+ return lines.join("\n");
123
+ }
124
+
125
+ /**
126
+ * Add a new criterion to the description.
127
+ * Appends to the Acceptance Criteria section if it exists,
128
+ * otherwise creates a new section at the end.
129
+ */
130
+ export function addCriterionToDescription(
131
+ description: string | undefined,
132
+ criterionText: string,
133
+ ): string {
134
+ if (!description) {
135
+ return `## Acceptance Criteria\n\n- [ ] ${criterionText}`;
136
+ }
137
+
138
+ const lines = description.split("\n");
139
+
140
+ // Find the AC section
141
+ let acSectionStart = -1;
142
+ let acSectionEnd = lines.length;
143
+
144
+ for (let i = 0; i < lines.length; i++) {
145
+ const trimmedLine = lines[i].trim().toLowerCase();
146
+
147
+ if (
148
+ trimmedLine.includes("acceptance criteria") &&
149
+ (trimmedLine.startsWith("#") || trimmedLine.startsWith("**"))
150
+ ) {
151
+ acSectionStart = i;
152
+ continue;
153
+ }
154
+
155
+ // If we're past the AC header, look for next section
156
+ if (acSectionStart !== -1 && i > acSectionStart) {
157
+ const line = lines[i].trim();
158
+ if (
159
+ line.startsWith("#") ||
160
+ (line.startsWith("**") && line.endsWith("**"))
161
+ ) {
162
+ acSectionEnd = i;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+
168
+ // Find the last checkbox in the AC section to insert after
169
+ let insertIndex = acSectionEnd;
170
+ if (acSectionStart !== -1) {
171
+ for (let i = acSectionEnd - 1; i > acSectionStart; i--) {
172
+ if (lines[i].match(/^[\s]*[-*]\s*\[([ xX])\]/)) {
173
+ insertIndex = i + 1;
174
+ break;
175
+ }
176
+ }
177
+ // If no checkboxes found, insert after the header
178
+ if (insertIndex === acSectionEnd) {
179
+ insertIndex = acSectionStart + 1;
180
+ // Skip empty lines after header
181
+ while (insertIndex < lines.length && lines[insertIndex].trim() === "") {
182
+ insertIndex++;
183
+ }
184
+ }
185
+ } else {
186
+ // No AC section exists, add one at the end
187
+ lines.push("");
188
+ lines.push("## Acceptance Criteria");
189
+ lines.push("");
190
+ insertIndex = lines.length;
191
+ }
192
+
193
+ // Insert the new criterion
194
+ lines.splice(insertIndex, 0, `- [ ] ${criterionText}`);
195
+
196
+ return lines.join("\n");
197
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ addCriterionToDescription,
3
+ generateCriteriaId,
4
+ type ParsedCriterion,
5
+ parseCriteriaFromDescription,
6
+ updateCriterionInDescription,
7
+ } from "./criteria-parser.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Linear Adapter Module
3
+ *
4
+ * Public exports for the Linear adapter.
5
+ * Only LinearAdapter is exposed; internal types remain private.
6
+ */
7
+
8
+ export { LinearAdapter } from "./adapter.js";
9
+ export { LinearApiError, LinearClient } from "./client.js";
10
+ export {
11
+ linearConfigExists,
12
+ loadLinearConfig,
13
+ saveLinearConfig,
14
+ validateLinearConfig,
15
+ } from "./config.js";
16
+ export type { LinearConfig } from "./types.js";
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Description formatters for embedding acceptance criteria in Linear issue descriptions.
3
+ *
4
+ * Linear doesn't have native acceptance criteria fields, so we embed them
5
+ * as markdown checklists in the issue description.
6
+ */
7
+
8
+ export interface DescriptionInput {
9
+ description?: string;
10
+ acceptanceCriteria?: string[];
11
+ }
12
+
13
+ export interface ParsedDescription {
14
+ description: string;
15
+ acceptanceCriteria: Array<{
16
+ text: string;
17
+ isCompleted: boolean;
18
+ }>;
19
+ }
20
+
21
+ const AC_SECTION_HEADER = "## Acceptance Criteria";
22
+ const AC_SECTION_REGEX = /^## Acceptance Criteria\s*$/m;
23
+ const AC_ITEM_REGEX = /^-\s*\[([^\]]*)\]\s*(.+)$/;
24
+
25
+ /**
26
+ * Format description with embedded acceptance criteria checklist.
27
+ *
28
+ * @param input - Description and acceptance criteria to format
29
+ * @returns Formatted description with AC checklist
30
+ */
31
+ export function formatDescription(input: DescriptionInput): string {
32
+ const { description = "", acceptanceCriteria = [] } = input;
33
+
34
+ // If no AC, return description as-is
35
+ if (!acceptanceCriteria || acceptanceCriteria.length === 0) {
36
+ return description;
37
+ }
38
+
39
+ // Remove existing AC section if present
40
+ let baseDescription = description;
41
+ const acSectionMatch = baseDescription.match(AC_SECTION_REGEX);
42
+ if (acSectionMatch && acSectionMatch.index !== undefined) {
43
+ const acSectionIndex = acSectionMatch.index;
44
+ baseDescription = baseDescription.substring(0, acSectionIndex).trim();
45
+ }
46
+
47
+ // Build AC checklist
48
+ const acLines = acceptanceCriteria.map((criterion) => `- [ ] ${criterion}`);
49
+ const acSection = `${AC_SECTION_HEADER}\n${acLines.join("\n")}`;
50
+
51
+ // Combine description and AC section
52
+ if (baseDescription) {
53
+ return `${baseDescription}\n\n${acSection}`;
54
+ }
55
+ return acSection;
56
+ }
57
+
58
+ /**
59
+ * Parse description to extract acceptance criteria.
60
+ *
61
+ * @param content - Description content with embedded AC
62
+ * @returns Parsed description and AC list
63
+ */
64
+ export function parseDescription(content: string): ParsedDescription {
65
+ if (!content) {
66
+ return { description: "", acceptanceCriteria: [] };
67
+ }
68
+
69
+ // Find AC section
70
+ const acSectionMatch = content.match(AC_SECTION_REGEX);
71
+ if (!acSectionMatch || acSectionMatch.index === undefined) {
72
+ return { description: content.trim(), acceptanceCriteria: [] };
73
+ }
74
+
75
+ const acSectionIndex = acSectionMatch.index;
76
+ const description = content.substring(0, acSectionIndex).trim();
77
+
78
+ // Extract AC items from section
79
+ const acSectionContent = content.substring(acSectionIndex);
80
+ const lines = acSectionContent.split("\n");
81
+ const acceptanceCriteria: Array<{ text: string; isCompleted: boolean }> = [];
82
+
83
+ for (const line of lines) {
84
+ const match = line.match(AC_ITEM_REGEX);
85
+ if (match) {
86
+ const [, checkbox, text] = match;
87
+ const checkboxTrimmed = checkbox.trim().toLowerCase();
88
+ acceptanceCriteria.push({
89
+ text: text.trim(),
90
+ isCompleted: checkboxTrimmed === "x",
91
+ });
92
+ }
93
+ }
94
+
95
+ return { description, acceptanceCriteria };
96
+ }
97
+
98
+ /**
99
+ * Update completion status of AC in description.
100
+ *
101
+ * @param content - Description content with embedded AC
102
+ * @param acText - Text of the AC item to update
103
+ * @param isCompleted - New completion status
104
+ * @returns Updated description content
105
+ */
106
+ export function updateAcCompletion(
107
+ content: string,
108
+ acText: string,
109
+ isCompleted: boolean,
110
+ ): string {
111
+ // Find AC section
112
+ const acSectionMatch = content.match(AC_SECTION_REGEX);
113
+ if (!acSectionMatch) {
114
+ return content;
115
+ }
116
+
117
+ const checkbox = isCompleted ? "[x]" : "[ ]";
118
+ const lines = content.split("\n");
119
+ let updated = false;
120
+
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ const match = line.match(AC_ITEM_REGEX);
124
+ if (match) {
125
+ const [, , text] = match;
126
+ // Exact match only to avoid partial matches
127
+ if (text.trim() === acText) {
128
+ lines[i] = `- ${checkbox} ${text.trim()}`;
129
+ updated = true;
130
+ break;
131
+ }
132
+ }
133
+ }
134
+
135
+ return updated ? lines.join("\n") : content;
136
+ }