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 +2 -4
- package/src/agent/loop.ts +10 -6
- package/src/context/linear.ts +35 -6
- package/src/llm/index.ts +2 -2
- package/src/llm/provider.ts +67 -0
- package/src/review/engine.ts +27 -4
- package/src/review/formatter.ts +38 -1
- package/src/types/review.ts +21 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "specvector",
|
|
3
|
-
"version": "0.1
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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) {
|
package/src/context/linear.ts
CHANGED
|
@@ -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
|
|
86
|
+
* Returns formatted context string and extracted metadata.
|
|
80
87
|
*/
|
|
81
88
|
export async function fetchLinearContext(
|
|
82
89
|
ticketId: string
|
|
83
|
-
): Promise<Result<
|
|
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,
|
|
137
|
-
|
|
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
|
|
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 {
|
package/src/llm/provider.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/review/engine.ts
CHANGED
|
@@ -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:
|
|
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
|
}
|
package/src/review/formatter.ts
CHANGED
|
@@ -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
|
*/
|
package/src/types/review.ts
CHANGED
|
@@ -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
|
/**
|