specvector 0.1.8 → 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.8",
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) {
@@ -73,14 +73,21 @@ export function extractTicketId(
73
73
  // Linear Context Fetching
74
74
  // ============================================================================
75
75
 
76
+ /** Extended ticket data from Linear MCP */
77
+ export interface LinearTicketData {
78
+ context: string;
79
+ title?: string;
80
+ url?: string;
81
+ }
82
+
76
83
  /**
77
84
  * Fetch Linear ticket context for a review.
78
85
  *
79
- * Returns formatted context string to inject into review prompt.
86
+ * Returns formatted context string and extracted metadata.
80
87
  */
81
88
  export async function fetchLinearContext(
82
89
  ticketId: string
83
- ): Promise<Result<string, LinearContextError>> {
90
+ ): Promise<Result<LinearTicketData, LinearContextError>> {
84
91
  // Check for API token
85
92
  const apiToken = process.env.LINEAR_API_TOKEN;
86
93
  if (!apiToken) {
@@ -132,9 +139,25 @@ export async function fetchLinearContext(
132
139
  });
133
140
  }
134
141
 
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];
152
+
135
153
  // Format the context for the review prompt
136
- const contextBlock = formatLinearContext(ticketId, textContent.text);
137
- return ok(contextBlock);
154
+ const contextBlock = formatLinearContext(ticketId, ticketText);
155
+
156
+ return ok({
157
+ context: contextBlock,
158
+ title: extractedTitle,
159
+ url: extractedUrl,
160
+ });
138
161
 
139
162
  } finally {
140
163
  await client.close();
@@ -167,7 +190,7 @@ export async function getLinearContextForReview(
167
190
  branchName?: string,
168
191
  prTitle?: string,
169
192
  prBody?: string
170
- ): Promise<{ context: string | null; ticketId: string | null; warning?: string }> {
193
+ ): Promise<{ context: string | null; ticketId: string | null; ticketTitle?: string; ticketUrl?: string; warning?: string }> {
171
194
  // Extract ticket ID
172
195
  const ticketId = extractTicketId(branchName, prTitle, prBody);
173
196
 
@@ -195,8 +218,14 @@ export async function getLinearContextForReview(
195
218
  };
196
219
  }
197
220
 
221
+ const { context, title, url } = result.value;
222
+
198
223
  return {
199
- context: result.value,
224
+ context,
200
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,
201
230
  };
202
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
  /**