specvector 0.1.9 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specvector",
3
- "version": "0.1.9",
3
+ "version": "0.3.1",
4
4
  "description": "Context-aware AI code review using Model Context Protocol (MCP)",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -44,9 +44,7 @@
44
44
  "engines": {
45
45
  "bun": ">=1.0.0"
46
46
  },
47
- "dependencies": {
48
- "@larryhudson/linear-mcp-server": "0.1.4"
49
- },
47
+ "dependencies": {},
50
48
  "devDependencies": {
51
49
  "@types/bun": "latest"
52
50
  },
package/src/agent/loop.ts CHANGED
@@ -17,6 +17,7 @@
17
17
  import { ok, err } from "../types/result";
18
18
  import type { Message, ToolCall } from "../types/llm";
19
19
  import type { LLMProvider, LLMError } from "../llm/provider";
20
+ import { withRetry } from "../llm/provider";
20
21
  import type {
21
22
  Tool,
22
23
  AgentConfig,
@@ -70,12 +71,15 @@ export class AgentLoop {
70
71
  return err(AgentErrors.timeout());
71
72
  }
72
73
 
73
- // Get LLM response
74
- const llmResult = await this.provider.chat(
75
- messages,
76
- {
77
- tools: Array.from(this.tools.values()).map(toolToLLMTool),
78
- }
74
+ // Get LLM response with retry on transient errors
75
+ const llmResult = await withRetry(
76
+ () => this.provider.chat(
77
+ messages,
78
+ {
79
+ tools: Array.from(this.tools.values()).map(toolToLLMTool),
80
+ }
81
+ ),
82
+ { maxRetries: 1, delayMs: 2000 }
79
83
  );
80
84
 
81
85
  if (!llmResult.ok) {
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Linear Context Provider - Fetches ticket context for reviews.
3
3
  *
4
- * Extracts ticket ID from branch name/PR and fetches details via Linear GraphQL API.
4
+ * Extracts ticket ID from branch name/PR and fetches details via MCP.
5
5
  */
6
6
 
7
+ import { createMCPClient } from "../mcp";
7
8
  import type { Result } from "../types/result";
8
9
  import { ok, err } from "../types/result";
9
10
 
@@ -12,12 +13,12 @@ import { ok, err } from "../types/result";
12
13
  // ============================================================================
13
14
 
14
15
  export interface LinearTicket {
15
- identifier?: string;
16
- title?: string;
17
- description?: string;
18
- state?: { name?: string };
19
- priority?: number;
20
- labels?: { nodes?: Array<{ name?: string }> };
16
+ id: string;
17
+ title: string;
18
+ description: string;
19
+ state: string;
20
+ priority: number;
21
+ labels: string[];
21
22
  }
22
23
 
23
24
  export interface LinearContextError {
@@ -72,14 +73,21 @@ export function extractTicketId(
72
73
  // Linear Context Fetching
73
74
  // ============================================================================
74
75
 
76
+ /** Extended ticket data from Linear MCP */
77
+ export interface LinearTicketData {
78
+ context: string;
79
+ title?: string;
80
+ url?: string;
81
+ }
82
+
75
83
  /**
76
84
  * Fetch Linear ticket context for a review.
77
85
  *
78
- * Uses Linear's GraphQL API directly - simpler and more reliable than MCP.
86
+ * Returns formatted context string and extracted metadata.
79
87
  */
80
88
  export async function fetchLinearContext(
81
89
  ticketId: string
82
- ): Promise<Result<string, LinearContextError>> {
90
+ ): Promise<Result<LinearTicketData, LinearContextError>> {
83
91
  // Check for API token
84
92
  const apiToken = process.env.LINEAR_API_TOKEN;
85
93
  if (!apiToken) {
@@ -89,85 +97,83 @@ export async function fetchLinearContext(
89
97
  });
90
98
  }
91
99
 
92
- try {
93
- // Use Linear GraphQL API directly
94
- const response = await fetch("https://api.linear.app/graphql", {
95
- method: "POST",
96
- headers: {
97
- "Content-Type": "application/json",
98
- Authorization: apiToken,
99
- },
100
- body: JSON.stringify({
101
- query: `
102
- query GetIssue($id: String!) {
103
- issue(id: $id) {
104
- identifier
105
- title
106
- description
107
- state { name }
108
- priority
109
- labels { nodes { name } }
110
- }
111
- }
112
- `,
113
- variables: { id: ticketId },
114
- }),
100
+ // Create MCP client
101
+ const clientResult = await createMCPClient({
102
+ name: "linear",
103
+ command: "npx",
104
+ args: ["linear-mcp-server"],
105
+ env: {
106
+ LINEAR_API_KEY: apiToken,
107
+ },
108
+ timeout: 30000,
109
+ });
110
+
111
+ if (!clientResult.ok) {
112
+ return err({
113
+ code: "CONNECTION_FAILED",
114
+ message: clientResult.error.message,
115
115
  });
116
+ }
117
+
118
+ const client = clientResult.value;
116
119
 
117
- if (!response.ok) {
120
+ try {
121
+ // Fetch issue details using Linear MCP
122
+ const ticketResult = await client.callTool("get_issue", { issue_id: ticketId });
123
+
124
+ if (!ticketResult.ok) {
118
125
  return err({
119
126
  code: "FETCH_FAILED",
120
- message: `Linear API error: ${response.status}`,
127
+ message: ticketResult.error.message,
121
128
  });
122
129
  }
123
130
 
124
- const data = await response.json() as { data?: { issue?: LinearTicket | null }; errors?: Array<{ message: string }> };
131
+ // Parse the response
132
+ const content = ticketResult.value.content;
133
+ const textContent = content.find(c => c.type === "text");
125
134
 
126
- if (data.errors?.length) {
135
+ if (!textContent?.text) {
127
136
  return err({
128
137
  code: "FETCH_FAILED",
129
- message: data.errors[0]?.message ?? "GraphQL error",
138
+ message: "No ticket data returned",
130
139
  });
131
140
  }
132
141
 
133
- const issue = data.data?.issue;
134
- if (!issue) {
135
- return err({
136
- code: "FETCH_FAILED",
137
- message: `Issue ${ticketId} not found`,
138
- });
139
- }
142
+ const ticketText = textContent.text;
143
+
144
+ // Extract URL from MCP response if present (Linear API includes url field)
145
+ // Pattern matches: "url": "https://linear.app/..." or URL: https://linear.app/...
146
+ const urlMatch = ticketText.match(/"?url"?:\s*"?(https:\/\/linear\.app\/[^"\s]+)"?/i);
147
+ const extractedUrl = urlMatch?.[1];
148
+
149
+ // Extract title from MCP response if present
150
+ const titleMatch = ticketText.match(/"?title"?:\s*"([^"]+)"/i);
151
+ const extractedTitle = titleMatch?.[1];
140
152
 
141
153
  // Format the context for the review prompt
142
- const contextBlock = formatLinearContext(ticketId, issue);
143
- return ok(contextBlock);
144
-
145
- } catch (error) {
146
- return err({
147
- code: "FETCH_FAILED",
148
- message: error instanceof Error ? error.message : "Unknown error",
154
+ const contextBlock = formatLinearContext(ticketId, ticketText);
155
+
156
+ return ok({
157
+ context: contextBlock,
158
+ title: extractedTitle,
159
+ url: extractedUrl,
149
160
  });
161
+
162
+ } finally {
163
+ await client.close();
150
164
  }
151
165
  }
152
166
 
153
167
  /**
154
- * Format Linear issue data as context for review prompt.
168
+ * Format Linear ticket data as context for review prompt.
155
169
  */
156
- function formatLinearContext(ticketId: string, issue: { title?: string; description?: string; state?: { name?: string }; priority?: number; labels?: { nodes?: Array<{ name?: string }> } }): string {
157
- const labels = issue.labels?.nodes?.map(l => l.name).filter(Boolean).join(", ") || "none";
158
- const priority = issue.priority ?? 0;
159
- const priorityLabels = ["", "Urgent", "High", "Medium", "Low", "No Priority"];
160
-
170
+ function formatLinearContext(ticketId: string, ticketData: string): string {
161
171
  return `
162
172
  ## Linear Ticket Context: ${ticketId}
163
173
 
164
- **Title:** ${issue.title ?? "Untitled"}
165
- **Status:** ${issue.state?.name ?? "Unknown"}
166
- **Priority:** ${priorityLabels[priority] ?? "Unknown"}
167
- **Labels:** ${labels}
174
+ The following is the context from the linked Linear ticket. Use this to understand the business requirements and acceptance criteria for this PR.
168
175
 
169
- ### Description
170
- ${issue.description ?? "No description provided."}
176
+ ${ticketData}
171
177
 
172
178
  ---
173
179
  When reviewing, check that the PR changes align with the ticket requirements above.
@@ -184,7 +190,7 @@ export async function getLinearContextForReview(
184
190
  branchName?: string,
185
191
  prTitle?: string,
186
192
  prBody?: string
187
- ): Promise<{ context: string | null; ticketId: string | null; warning?: string }> {
193
+ ): Promise<{ context: string | null; ticketId: string | null; ticketTitle?: string; ticketUrl?: string; warning?: string }> {
188
194
  // Extract ticket ID
189
195
  const ticketId = extractTicketId(branchName, prTitle, prBody);
190
196
 
@@ -212,8 +218,14 @@ export async function getLinearContextForReview(
212
218
  };
213
219
  }
214
220
 
221
+ const { context, title, url } = result.value;
222
+
215
223
  return {
216
- context: result.value,
224
+ context,
217
225
  ticketId,
226
+ // Use extracted title from MCP if available, fallback to generic
227
+ ticketTitle: title ?? `Linear Ticket ${ticketId}`,
228
+ // Use extracted URL from MCP if available (no fallback - better to have no link than broken link)
229
+ ticketUrl: url,
218
230
  };
219
231
  }
package/src/llm/index.ts CHANGED
@@ -33,8 +33,8 @@ export type {
33
33
  } from "../types/llm";
34
34
 
35
35
  // Re-export provider interface and errors
36
- export type { LLMProvider, LLMError, LLMErrorCode } from "./provider";
37
- export { LLMErrors, isRetryableError, createLLMError } from "./provider";
36
+ export type { LLMProvider, LLMError, LLMErrorCode, RetryConfig } from "./provider";
37
+ export { LLMErrors, isRetryableError, createLLMError, withRetry } from "./provider";
38
38
 
39
39
  // Re-export factory
40
40
  export {
@@ -131,3 +131,70 @@ export const LLMErrors = {
131
131
  retryable: false,
132
132
  }),
133
133
  };
134
+
135
+ /**
136
+ * Retry configuration for LLM calls.
137
+ */
138
+ export interface RetryConfig {
139
+ /** Maximum number of retries (default: 1) */
140
+ maxRetries?: number;
141
+ /** Delay between retries in ms (default: 2000) */
142
+ delayMs?: number;
143
+ /** Whether to log retry attempts (default: true) */
144
+ logRetries?: boolean;
145
+ }
146
+
147
+ /**
148
+ * Wrap an async function with retry logic for transient errors.
149
+ *
150
+ * @param fn - Async function returning Result<T, LLMError>
151
+ * @param config - Retry configuration
152
+ * @returns Result with success value or final error
153
+ */
154
+ export async function withRetry<T>(
155
+ fn: () => Promise<Result<T, LLMError>>,
156
+ config: RetryConfig = {}
157
+ ): Promise<Result<T, LLMError>> {
158
+ const maxRetries = config.maxRetries ?? 1;
159
+ const delayMs = config.delayMs ?? 2000;
160
+ const logRetries = config.logRetries ?? true;
161
+
162
+ let lastError: LLMError | undefined;
163
+
164
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
165
+ const result = await fn();
166
+
167
+ if (result.ok) {
168
+ return result;
169
+ }
170
+
171
+ lastError = result.error;
172
+
173
+ // Check if we should retry
174
+ if (attempt < maxRetries && isRetryableError(result.error)) {
175
+ const retryDelay = result.error.retryAfterMs ?? delayMs;
176
+
177
+ if (logRetries) {
178
+ console.warn(
179
+ `⚠️ LLM call failed (${result.error.code}), retrying in ${retryDelay}ms... (${attempt + 1}/${maxRetries})`
180
+ );
181
+ }
182
+
183
+ await sleep(retryDelay);
184
+ continue;
185
+ }
186
+
187
+ // Non-retryable error or max retries reached
188
+ return result;
189
+ }
190
+
191
+ // Should not reach here, but return last error if we do
192
+ return { ok: false, error: lastError! };
193
+ }
194
+
195
+ /**
196
+ * Sleep for a given number of milliseconds.
197
+ */
198
+ function sleep(ms: number): Promise<void> {
199
+ return new Promise(resolve => setTimeout(resolve, ms));
200
+ }
@@ -16,7 +16,7 @@ import { createListDirTool } from "../agent/tools/list-dir";
16
16
  import { createOutlineTool } from "../agent/tools/outline";
17
17
  import { createFindSymbolTool } from "../agent/tools/find-symbol";
18
18
  import { calculateStats, determineRecommendation } from "../types/review";
19
- import type { ReviewResult, ReviewFinding, Severity } from "../types/review";
19
+ import type { ReviewResult, ReviewFinding, Severity, ContextSource } from "../types/review";
20
20
  import type { Result } from "../types/result";
21
21
  import { ok, err } from "../types/result";
22
22
  import { loadConfig, getStrictnessModifier } from "../config";
@@ -100,6 +100,9 @@ export async function runReview(
100
100
  const strictnessGuidance = getStrictnessModifier(strictness);
101
101
  let systemPrompt = REVIEW_SYSTEM_PROMPT + `\n\n## Strictness Setting: ${strictness.toUpperCase()}\n${strictnessGuidance}`;
102
102
 
103
+ // Track context sources for citation
104
+ const contextSources: ContextSource[] = [];
105
+
103
106
  // Fetch Linear context if available
104
107
  const linearResult = await getLinearContextForReview(
105
108
  config.branchName,
@@ -110,6 +113,16 @@ export async function runReview(
110
113
  if (linearResult.context) {
111
114
  systemPrompt = linearResult.context + "\n\n" + systemPrompt;
112
115
  console.log(`📎 Loaded Linear context for ticket: ${linearResult.ticketId}`);
116
+
117
+ // Track Linear as a context source (only if we have a valid ticket ID)
118
+ if (linearResult.ticketId) {
119
+ contextSources.push({
120
+ type: "linear",
121
+ id: linearResult.ticketId,
122
+ title: linearResult.ticketTitle,
123
+ url: linearResult.ticketUrl,
124
+ });
125
+ }
113
126
  } else if (linearResult.warning) {
114
127
  console.warn(`⚠️ ${linearResult.warning}`);
115
128
  }
@@ -119,12 +132,21 @@ export async function runReview(
119
132
  if (adrResult) {
120
133
  systemPrompt = adrResult.formatted + "\n\n" + systemPrompt;
121
134
  console.log(`📚 Loaded ${adrResult.context.count} ADR files from ${adrResult.context.path}`);
135
+
136
+ // Track each ADR file as a context source
137
+ for (const adrFile of adrResult.context.files) {
138
+ contextSources.push({
139
+ type: "adr",
140
+ id: adrFile.name,
141
+ title: adrFile.name.replace(".md", ""),
142
+ });
143
+ }
122
144
  }
123
145
 
124
146
  // Create agent
125
147
  const agent = createAgentLoop(providerResult.value, tools, {
126
148
  maxIterations: config.maxIterations || fileConfig.maxIterations || 15,
127
- timeoutMs: 3 * 60 * 1000, // 3 minutes
149
+ timeoutMs: 15 * 60 * 1000, // 15 minutes for larger PRs with slower models
128
150
  systemPrompt,
129
151
  });
130
152
 
@@ -142,7 +164,7 @@ export async function runReview(
142
164
  }
143
165
 
144
166
  // Parse the response into structured findings
145
- const reviewResult = parseReviewResponse(agentResult.value, diffSummary);
167
+ const reviewResult = parseReviewResponse(agentResult.value, diffSummary, contextSources);
146
168
 
147
169
  return ok(reviewResult);
148
170
  }
@@ -214,7 +236,7 @@ ${diff.length > 15000 ? "\n(diff truncated, use tools to read full files if need
214
236
  /**
215
237
  * Parse the agent's response into structured findings.
216
238
  */
217
- function parseReviewResponse(response: string, diffSummary: string): ReviewResult {
239
+ function parseReviewResponse(response: string, diffSummary: string, contextSources: ContextSource[] = []): ReviewResult {
218
240
  const findings: ReviewFinding[] = [];
219
241
 
220
242
  // Extract summary
@@ -270,5 +292,6 @@ function parseReviewResponse(response: string, diffSummary: string): ReviewResul
270
292
  recommendation,
271
293
  stats,
272
294
  filesReviewed: parseInt(filesReviewed, 10) || 0,
295
+ contextSources: contextSources.length > 0 ? contextSources : undefined,
273
296
  };
274
297
  }
@@ -3,7 +3,7 @@
3
3
  * Converts ReviewResult into markdown for PR comments.
4
4
  */
5
5
 
6
- import type { ReviewFinding, ReviewResult, Severity } from "../types/review";
6
+ import type { ReviewFinding, ReviewResult, Severity, ContextSource } from "../types/review";
7
7
 
8
8
  /**
9
9
  * Severity emoji indicators.
@@ -60,6 +60,14 @@ export function formatReviewComment(result: ReviewResult): string {
60
60
  lines.push(result.summary);
61
61
  lines.push("");
62
62
 
63
+ // Context Used section (if any context was used)
64
+ if (result.contextSources && result.contextSources.length > 0) {
65
+ lines.push("### Context Used");
66
+ lines.push("");
67
+ lines.push(...formatContextSources(result.contextSources));
68
+ lines.push("");
69
+ }
70
+
63
71
  // Findings by severity (only include sections with findings)
64
72
  if (result.stats.critical > 0) {
65
73
  lines.push("---");
@@ -134,6 +142,35 @@ function formatFindings(findings: ReviewFinding[], severity: Severity): string[]
134
142
  return lines;
135
143
  }
136
144
 
145
+ /**
146
+ * Format context sources for the Context Used section.
147
+ * Renders Linear tickets with clickable links and ADR files as a list.
148
+ */
149
+ function formatContextSources(sources: ContextSource[]): string[] {
150
+ const lines: string[] = [];
151
+
152
+ // Group sources by type
153
+ const linearSources = sources.filter(s => s.type === "linear");
154
+ const adrSources = sources.filter(s => s.type === "adr");
155
+
156
+ // Format Linear tickets
157
+ for (const source of linearSources) {
158
+ if (source.url) {
159
+ lines.push(`- **Linear ticket:** [${source.id}](${source.url})`);
160
+ } else {
161
+ lines.push(`- **Linear ticket:** ${source.id}`);
162
+ }
163
+ }
164
+
165
+ // Format ADR files
166
+ if (adrSources.length > 0) {
167
+ const adrList = adrSources.map(s => `\`${s.id}\``).join(", ");
168
+ lines.push(`- **ADRs referenced:** ${adrList}`);
169
+ }
170
+
171
+ return lines;
172
+ }
173
+
137
174
  /**
138
175
  * Format recommendation as human-readable text.
139
176
  */
@@ -32,6 +32,25 @@ export interface ReviewFinding {
32
32
  */
33
33
  export type Recommendation = "APPROVE" | "REQUEST_CHANGES" | "COMMENT";
34
34
 
35
+ /**
36
+ * Type of external context source used in the review.
37
+ */
38
+ export type ContextSourceType = "linear" | "adr";
39
+
40
+ /**
41
+ * External context source that informed the review.
42
+ */
43
+ export interface ContextSource {
44
+ /** Type of context source */
45
+ type: ContextSourceType;
46
+ /** Identifier (ticket ID or file path) */
47
+ id: string;
48
+ /** Display title */
49
+ title?: string;
50
+ /** URL for clickable link (Linear tickets) */
51
+ url?: string;
52
+ }
53
+
35
54
  /**
36
55
  * Statistics about the review findings.
37
56
  */
@@ -57,6 +76,8 @@ export interface ReviewResult {
57
76
  stats: ReviewStats;
58
77
  /** Files that were reviewed */
59
78
  filesReviewed: number;
79
+ /** External context sources used in the review */
80
+ contextSources?: ContextSource[];
60
81
  }
61
82
 
62
83
  /**