ai-spec-dev 0.25.0 → 0.28.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/core/reviewer.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  reviewSystemPrompt,
8
8
  reviewArchitectureSystemPrompt,
9
9
  reviewImplementationSystemPrompt,
10
+ reviewImpactComplexitySystemPrompt,
10
11
  } from "../prompts/codegen.prompt";
11
12
 
12
13
  // ─── Review History ────────────────────────────────────────────────────────────
@@ -16,6 +17,8 @@ interface ReviewHistoryEntry {
16
17
  specFile: string;
17
18
  score: number;
18
19
  topIssues: string[];
20
+ impactLevel?: "低" | "中" | "高";
21
+ complexityLevel?: "低" | "中" | "高";
19
22
  }
20
23
 
21
24
  const REVIEW_HISTORY_FILE = ".ai-spec-reviews.json";
@@ -53,6 +56,18 @@ function extractScore(reviewText: string): number {
53
56
  return match ? parseFloat(match[1]) : 0;
54
57
  }
55
58
 
59
+ /** Extract impact level from Pass 3 review ("影响等级:低/中/高") */
60
+ function extractImpactLevel(reviewText: string): "低" | "中" | "高" | undefined {
61
+ const match = reviewText.match(/影响等级[::]\s*(低|中|高)/);
62
+ return match ? (match[1] as "低" | "中" | "高") : undefined;
63
+ }
64
+
65
+ /** Extract complexity level from Pass 3 review ("复杂度等级:低/中/高") */
66
+ function extractComplexityLevel(reviewText: string): "低" | "中" | "高" | undefined {
67
+ const match = reviewText.match(/复杂度等级[::]\s*(低|中|高)/);
68
+ return match ? (match[1] as "低" | "中" | "高") : undefined;
69
+ }
70
+
56
71
  /** Extract top issue lines from a review result (lines starting with - or · under ⚠️ section) */
57
72
  function extractTopIssues(reviewText: string): string[] {
58
73
  const issuesSection = reviewText.match(/##.*?问题.*?\n([\s\S]*?)(?=##|$)/i)?.[1] ?? "";
@@ -112,19 +127,18 @@ export class CodeReviewer {
112
127
  }
113
128
 
114
129
  /**
115
- * Two-pass review:
130
+ * Three-pass review:
116
131
  * Pass 1 — architecture (spec compliance, layer separation, auth)
117
132
  * Pass 2 — implementation details (validation, error handling, edge cases)
118
133
  * + historical issue recurrence check
119
- *
120
- * Falls back to single-pass if the two-pass flag is not set.
134
+ * Pass 3 — impact assessment + code complexity
121
135
  */
122
- private async runTwoPassReview(
136
+ private async runThreePassReview(
123
137
  specContent: string,
124
138
  codeContext: string,
125
139
  specFile?: string
126
140
  ): Promise<string> {
127
- console.log(chalk.gray(" Pass 1/2: Architecture review..."));
141
+ console.log(chalk.gray(" Pass 1/3: Architecture review..."));
128
142
 
129
143
  // ── Pass 1: Architecture ──────────────────────────────────────────────────
130
144
  const archPrompt = `Review the architecture of this change.
@@ -136,7 +150,7 @@ ${specContent || "(No spec — review for general code quality)"}
136
150
  ${codeContext}`;
137
151
 
138
152
  const archReview = await this.provider.generate(archPrompt, reviewArchitectureSystemPrompt);
139
- console.log(chalk.gray(" Pass 2/2: Implementation review..."));
153
+ console.log(chalk.gray(" Pass 2/3: Implementation review..."));
140
154
 
141
155
  // ── Pass 2: Implementation + History ─────────────────────────────────────
142
156
  const history = await loadReviewHistory(this.projectRoot);
@@ -155,19 +169,42 @@ ${archReview}
155
169
  ${historyContext}`;
156
170
 
157
171
  const implReview = await this.provider.generate(implPrompt, reviewImplementationSystemPrompt);
172
+ console.log(chalk.gray(" Pass 3/3: Impact & complexity assessment..."));
173
+
174
+ // ── Pass 3: Impact & Complexity ───────────────────────────────────────────
175
+ const impactPrompt = `Assess the impact and complexity of this change.
176
+
177
+ === Feature Spec ===
178
+ ${specContent || "(No spec — review for general code quality)"}
179
+
180
+ === Code ===
181
+ ${codeContext}
182
+
183
+ === Architecture Review (Pass 1 — do NOT repeat) ===
184
+ ${archReview}
185
+
186
+ === Implementation Review (Pass 2 — do NOT repeat) ===
187
+ ${implReview}`;
188
+
189
+ const impactReview = await this.provider.generate(impactPrompt, reviewImpactComplexitySystemPrompt);
158
190
 
159
191
  // ── Combine ───────────────────────────────────────────────────────────────
160
- const combined = `${archReview}\n\n${"─".repeat(52)}\n\n${implReview}`;
192
+ const sep = "─".repeat(52);
193
+ const combined = `${archReview}\n\n${sep}\n\n${implReview}\n\n${sep}\n\n${impactReview}`;
161
194
 
162
195
  // ── Persist history ───────────────────────────────────────────────────────
163
196
  const score = extractScore(implReview) || extractScore(archReview);
164
197
  const topIssues = extractTopIssues(implReview);
198
+ const impactLevel = extractImpactLevel(impactReview);
199
+ const complexityLevel = extractComplexityLevel(impactReview);
165
200
  if (score > 0 && specFile) {
166
201
  await appendReviewHistory(this.projectRoot, {
167
202
  date: new Date().toISOString().slice(0, 10),
168
203
  specFile: path.relative(this.projectRoot, specFile),
169
204
  score,
170
205
  topIssues,
206
+ ...(impactLevel ? { impactLevel } : {}),
207
+ ...(complexityLevel ? { complexityLevel } : {}),
171
208
  });
172
209
  }
173
210
 
@@ -195,7 +232,7 @@ ${historyContext}`;
195
232
  );
196
233
 
197
234
  const codeContext = diff.slice(0, 10000);
198
- const reviewResult = await this.runTwoPassReview(specContent, codeContext, specFile);
235
+ const reviewResult = await this.runThreePassReview(specContent, codeContext, specFile);
199
236
 
200
237
  console.log(chalk.cyan("\n─── Review Result ───────────────────────────────"));
201
238
  console.log(reviewResult);
@@ -231,7 +268,7 @@ ${historyContext}`;
231
268
  }
232
269
  }
233
270
 
234
- const reviewResult = await this.runTwoPassReview(specContent, filesSection, specFile);
271
+ const reviewResult = await this.runThreePassReview(specContent, filesSection, specFile);
235
272
 
236
273
  console.log(chalk.cyan("\n─── Review Result ───────────────────────────────"));
237
274
  console.log(reviewResult);
@@ -0,0 +1,136 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+ import chalk from "chalk";
4
+
5
+ const LOG_DIR = ".ai-spec-logs";
6
+
7
+ // ─── Types ────────────────────────────────────────────────────────────────────
8
+
9
+ export interface LogEntry {
10
+ ts: string;
11
+ event: string;
12
+ durationMs?: number;
13
+ data?: Record<string, unknown>;
14
+ }
15
+
16
+ export interface RunLog {
17
+ runId: string;
18
+ startedAt: string;
19
+ workingDir: string;
20
+ provider?: string;
21
+ model?: string;
22
+ specPath?: string;
23
+ entries: LogEntry[];
24
+ filesWritten: string[];
25
+ errors: string[];
26
+ endedAt?: string;
27
+ totalDurationMs?: number;
28
+ }
29
+
30
+ // ─── RunLogger ────────────────────────────────────────────────────────────────
31
+
32
+ export class RunLogger {
33
+ private log: RunLog;
34
+ private readonly startMs: number;
35
+ private readonly logPath: string;
36
+ private readonly stageStartMs = new Map<string, number>();
37
+
38
+ constructor(
39
+ private readonly workingDir: string,
40
+ readonly runId: string,
41
+ meta?: { provider?: string; model?: string; specPath?: string }
42
+ ) {
43
+ this.startMs = Date.now();
44
+ this.logPath = path.join(workingDir, LOG_DIR, `${runId}.json`);
45
+ this.log = {
46
+ runId,
47
+ startedAt: new Date().toISOString(),
48
+ workingDir,
49
+ ...meta,
50
+ entries: [],
51
+ filesWritten: [],
52
+ errors: [],
53
+ };
54
+ this.flush();
55
+ }
56
+
57
+ stageStart(event: string, data?: Record<string, unknown>): void {
58
+ this.stageStartMs.set(event, Date.now());
59
+ this.push(event, data);
60
+ }
61
+
62
+ stageEnd(event: string, data?: Record<string, unknown>): void {
63
+ const start = this.stageStartMs.get(event);
64
+ const durationMs = start !== undefined ? Date.now() - start : undefined;
65
+ this.push(`${event}:done`, { ...data, durationMs });
66
+ }
67
+
68
+ stageFail(event: string, error: string, data?: Record<string, unknown>): void {
69
+ const start = this.stageStartMs.get(event);
70
+ const durationMs = start !== undefined ? Date.now() - start : undefined;
71
+ this.push(`${event}:failed`, { ...data, error, durationMs });
72
+ this.log.errors.push(`[${event}] ${error}`);
73
+ this.flush();
74
+ }
75
+
76
+ fileWritten(filePath: string): void {
77
+ if (!this.log.filesWritten.includes(filePath)) {
78
+ this.log.filesWritten.push(filePath);
79
+ this.flush();
80
+ }
81
+ }
82
+
83
+ finish(): void {
84
+ this.log.endedAt = new Date().toISOString();
85
+ this.log.totalDurationMs = Date.now() - this.startMs;
86
+ this.flush();
87
+ }
88
+
89
+ printSummary(): void {
90
+ const dur = this.log.totalDurationMs
91
+ ? ` in ${(this.log.totalDurationMs / 1000).toFixed(1)}s`
92
+ : "";
93
+ const errPart = this.log.errors.length > 0
94
+ ? chalk.yellow(` · ${this.log.errors.length} error(s)`)
95
+ : "";
96
+ console.log(
97
+ chalk.gray(`\n Run ID: ${chalk.white(this.runId)}${dur}`) +
98
+ chalk.gray(` · ${this.log.filesWritten.length} file(s) written`) +
99
+ errPart
100
+ );
101
+ console.log(chalk.gray(` Log : ${path.relative(this.workingDir, this.logPath)}`));
102
+ }
103
+
104
+ private push(event: string, data?: Record<string, unknown>): void {
105
+ this.log.entries.push({ ts: new Date().toISOString(), event, ...( data ? { data } : {}) });
106
+ this.flush();
107
+ }
108
+
109
+ private flush(): void {
110
+ fs.ensureDir(path.dirname(this.logPath))
111
+ .then(() => fs.writeJson(this.logPath, this.log, { spaces: 2 }))
112
+ .catch(() => {}); // logging must never crash the main pipeline
113
+ }
114
+ }
115
+
116
+ // ─── RunId ────────────────────────────────────────────────────────────────────
117
+
118
+ export function generateRunId(): string {
119
+ const now = new Date();
120
+ const date = now.toISOString().slice(0, 10).replace(/-/g, "");
121
+ const time = now.toTimeString().slice(0, 8).replace(/:/g, "");
122
+ const rand = Math.random().toString(36).slice(2, 6);
123
+ return `${date}-${time}-${rand}`;
124
+ }
125
+
126
+ // ─── Module-level singleton ────────────────────────────────────────────────────
127
+
128
+ let _activeLogger: RunLogger | null = null;
129
+
130
+ export function setActiveLogger(logger: RunLogger): void {
131
+ _activeLogger = logger;
132
+ }
133
+
134
+ export function getActiveLogger(): RunLogger | null {
135
+ return _activeLogger;
136
+ }
@@ -0,0 +1,84 @@
1
+ import * as fs from "fs-extra";
2
+ import * as path from "path";
3
+
4
+ const BACKUP_DIR = ".ai-spec-backup";
5
+
6
+ // ─── RunSnapshot ──────────────────────────────────────────────────────────────
7
+ /**
8
+ * Before a file is overwritten, copy its original content to
9
+ * `.ai-spec-backup/<runId>/<relative-path>`.
10
+ *
11
+ * Call `restore()` to roll back all changes made in this run.
12
+ */
13
+ export class RunSnapshot {
14
+ private readonly backupRoot: string;
15
+ private readonly snapshotted = new Set<string>();
16
+
17
+ constructor(
18
+ private readonly workingDir: string,
19
+ readonly runId: string
20
+ ) {
21
+ this.backupRoot = path.join(workingDir, BACKUP_DIR, runId);
22
+ }
23
+
24
+ /**
25
+ * Snapshot a file before it gets overwritten.
26
+ * No-op if the file does not exist yet (new file — nothing to restore).
27
+ * No-op if this file was already snapshotted in this run.
28
+ */
29
+ async snapshotFile(filePath: string): Promise<void> {
30
+ const fullPath = path.isAbsolute(filePath)
31
+ ? filePath
32
+ : path.join(this.workingDir, filePath);
33
+
34
+ if (!(await fs.pathExists(fullPath))) return;
35
+ if (this.snapshotted.has(fullPath)) return;
36
+
37
+ const relative = path.relative(this.workingDir, fullPath);
38
+ const dest = path.join(this.backupRoot, relative);
39
+ await fs.ensureDir(path.dirname(dest));
40
+ await fs.copy(fullPath, dest);
41
+ this.snapshotted.add(fullPath);
42
+ }
43
+
44
+ /** Restore all snapshotted files. Returns list of restored relative paths. */
45
+ async restore(): Promise<string[]> {
46
+ if (!(await fs.pathExists(this.backupRoot))) return [];
47
+
48
+ const restored: string[] = [];
49
+ const walk = async (dir: string) => {
50
+ for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
51
+ const full = path.join(dir, entry.name);
52
+ if (entry.isDirectory()) {
53
+ await walk(full);
54
+ } else {
55
+ const relative = path.relative(this.backupRoot, full);
56
+ const dest = path.join(this.workingDir, relative);
57
+ await fs.ensureDir(path.dirname(dest));
58
+ await fs.copy(full, dest, { overwrite: true });
59
+ restored.push(relative);
60
+ }
61
+ }
62
+ };
63
+ await walk(this.backupRoot);
64
+ return restored;
65
+ }
66
+
67
+ get fileCount(): number {
68
+ return this.snapshotted.size;
69
+ }
70
+ }
71
+
72
+ // ─── Module-level singleton ────────────────────────────────────────────────────
73
+ // Allows code-generator and error-feedback to access the active snapshot
74
+ // without prop-drilling through every function signature.
75
+
76
+ let _activeSnapshot: RunSnapshot | null = null;
77
+
78
+ export function setActiveSnapshot(snapshot: RunSnapshot): void {
79
+ _activeSnapshot = snapshot;
80
+ }
81
+
82
+ export function getActiveSnapshot(): RunSnapshot | null {
83
+ return _activeSnapshot;
84
+ }
@@ -5,6 +5,7 @@ import axios from "axios";
5
5
  import { ProxyAgent } from "undici";
6
6
  import { specPrompt } from "../prompts/spec.prompt";
7
7
  import { ProjectContext } from "./context-loader";
8
+ import { withReliability } from "./provider-utils";
8
9
 
9
10
  // ─── Proxy Helper ─────────────────────────────────────────────────────────────
10
11
  // 仅用于 Gemini:其他 SDK(Anthropic / OpenAI)会自动读取 HTTPS_PROXY。
@@ -207,12 +208,17 @@ export class GeminiProvider implements AIProvider {
207
208
  }
208
209
 
209
210
  async generate(prompt: string, systemInstruction?: string): Promise<string> {
210
- const model = this.genAI.getGenerativeModel(
211
- { model: this.modelName, ...(systemInstruction ? { systemInstruction } : {}) },
212
- geminiRequestOptions()
211
+ return withReliability(
212
+ async () => {
213
+ const model = this.genAI.getGenerativeModel(
214
+ { model: this.modelName, ...(systemInstruction ? { systemInstruction } : {}) },
215
+ geminiRequestOptions()
216
+ );
217
+ const result = await model.generateContent(prompt);
218
+ return result.response.text();
219
+ },
220
+ { label: `${this.providerName}/${this.modelName}` }
213
221
  );
214
- const result = await model.generateContent(prompt);
215
- return result.response.text();
216
222
  }
217
223
  }
218
224
 
@@ -229,15 +235,20 @@ export class ClaudeProvider implements AIProvider {
229
235
  }
230
236
 
231
237
  async generate(prompt: string, systemInstruction?: string): Promise<string> {
232
- const message = await this.client.messages.create({
233
- model: this.modelName,
234
- max_tokens: 8192,
235
- ...(systemInstruction ? { system: systemInstruction } : {}),
236
- messages: [{ role: "user", content: prompt }],
237
- });
238
- const block = message.content[0];
239
- if (block.type === "text") return block.text;
240
- throw new Error("Unexpected response type from Claude API");
238
+ return withReliability(
239
+ async () => {
240
+ const message = await this.client.messages.create({
241
+ model: this.modelName,
242
+ max_tokens: 8192,
243
+ ...(systemInstruction ? { system: systemInstruction } : {}),
244
+ messages: [{ role: "user", content: prompt }],
245
+ });
246
+ const block = message.content[0];
247
+ if (block.type === "text") return block.text;
248
+ throw new Error("Unexpected response type from Claude API");
249
+ },
250
+ { label: `${this.providerName}/${this.modelName}` }
251
+ );
241
252
  }
242
253
  }
243
254
 
@@ -270,25 +281,25 @@ export class OpenAICompatibleProvider implements AIProvider {
270
281
  }
271
282
 
272
283
  async generate(prompt: string, systemInstruction?: string): Promise<string> {
273
- const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
274
-
275
- if (systemInstruction) {
276
- // o1/o3 require "developer" role; gpt-4o/gpt-4-turbo use "system"
277
- // Auto-detect: if model starts with "o1" or "o3", use developer role
278
- const isOSeries = /^o[13]/.test(this.modelName);
279
- const role = isOSeries ? "developer" : this.systemRole;
280
- messages.push({ role, content: systemInstruction } as OpenAI.Chat.ChatCompletionMessageParam);
281
- }
282
- messages.push({ role: "user", content: prompt });
283
-
284
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
285
- const completion = await (this.client.chat.completions.create as any)({
286
- model: this.modelName,
287
- messages,
288
- ...(this.extraBody ? { extra_body: this.extraBody } : {}),
289
- });
290
-
291
- return (completion.choices[0].message.content as string) ?? "";
284
+ return withReliability(
285
+ async () => {
286
+ const messages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
287
+ if (systemInstruction) {
288
+ const isOSeries = /^o[13]/.test(this.modelName);
289
+ const role = isOSeries ? "developer" : this.systemRole;
290
+ messages.push({ role, content: systemInstruction } as OpenAI.Chat.ChatCompletionMessageParam);
291
+ }
292
+ messages.push({ role: "user", content: prompt });
293
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
294
+ const completion = await (this.client.chat.completions.create as any)({
295
+ model: this.modelName,
296
+ messages,
297
+ ...(this.extraBody ? { extra_body: this.extraBody } : {}),
298
+ });
299
+ return (completion.choices[0].message.content as string) ?? "";
300
+ },
301
+ { label: `${this.providerName}/${this.modelName}` }
302
+ );
292
303
  }
293
304
  }
294
305
 
@@ -310,41 +321,46 @@ export class MiMoProvider implements AIProvider {
310
321
  }
311
322
 
312
323
  async generate(prompt: string, systemInstruction?: string): Promise<string> {
313
- const body: Record<string, unknown> = {
314
- model: this.modelName,
315
- max_tokens: 16384,
316
- messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
317
- top_p: 0.95,
318
- stream: false,
319
- temperature: 1.0,
320
- stop_sequences: null,
321
- };
322
-
323
- if (systemInstruction) {
324
- body.system = systemInstruction;
325
- }
326
-
327
- const response = await axios.post(this.baseUrl, body, {
328
- headers: {
329
- "api-key": this.apiKey,
330
- "Content-Type": "application/json",
324
+ return withReliability(
325
+ async () => {
326
+ const body: Record<string, unknown> = {
327
+ model: this.modelName,
328
+ max_tokens: 16384,
329
+ messages: [{ role: "user", content: [{ type: "text", text: prompt }] }],
330
+ top_p: 0.95,
331
+ stream: false,
332
+ temperature: 1.0,
333
+ stop_sequences: null,
334
+ };
335
+
336
+ if (systemInstruction) {
337
+ body.system = systemInstruction;
338
+ }
339
+
340
+ const response = await axios.post(this.baseUrl, body, {
341
+ headers: {
342
+ "api-key": this.apiKey,
343
+ "Content-Type": "application/json",
344
+ },
345
+ });
346
+
347
+ // Response follows Anthropic format: { content: [{ type: "text"|"thinking", ... }] }
348
+ // MiMo may return a "thinking" block before the actual "text" block — skip it.
349
+ const data = response.data as { stop_reason?: string; content?: Array<{ type: string; text?: string; thinking?: string }> };
350
+ const blocks = data?.content ?? [];
351
+
352
+ const textBlock = blocks.find((b) => b.type === "text");
353
+ if (textBlock?.text) return textBlock.text;
354
+
355
+ // If stop_reason is max_tokens, the model was cut off mid-generation (thinking block only)
356
+ if (data?.stop_reason === "max_tokens") {
357
+ throw new Error(`MiMo response truncated (max_tokens reached). The prompt may be too long. Try a shorter spec or switch to a model with larger context.`);
358
+ }
359
+
360
+ throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
331
361
  },
332
- });
333
-
334
- // Response follows Anthropic format: { content: [{ type: "text"|"thinking", ... }] }
335
- // MiMo may return a "thinking" block before the actual "text" block — skip it.
336
- const data = response.data as { stop_reason?: string; content?: Array<{ type: string; text?: string; thinking?: string }> };
337
- const blocks = data?.content ?? [];
338
-
339
- const textBlock = blocks.find((b) => b.type === "text");
340
- if (textBlock?.text) return textBlock.text;
341
-
342
- // If stop_reason is max_tokens, the model was cut off mid-generation (thinking block only)
343
- if (data?.stop_reason === "max_tokens") {
344
- throw new Error(`MiMo response truncated (max_tokens reached). The prompt may be too long. Try a shorter spec or switch to a model with larger context.`);
345
- }
346
-
347
- throw new Error(`Unexpected MiMo response: ${JSON.stringify(response.data).slice(0, 200)}`);
362
+ { label: `${this.providerName}/${this.modelName}` }
363
+ );
348
364
  }
349
365
  }
350
366
 
@@ -155,10 +155,18 @@ export async function loadTasksForSpec(specFilePath: string): Promise<SpecTask[]
155
155
  const base = path.basename(specFilePath, ".md");
156
156
  const dir = path.dirname(specFilePath);
157
157
  const tasksFile = path.join(dir, `${base}-tasks.json`);
158
- if (await fs.pathExists(tasksFile)) {
159
- return fs.readJson(tasksFile);
158
+ if (!(await fs.pathExists(tasksFile))) return null;
159
+ try {
160
+ return await fs.readJson(tasksFile);
161
+ } catch {
162
+ // Corrupt or partially-written tasks file — warn and fall back to re-generation
163
+ // rather than crashing the entire CLI with a raw JSON parse error.
164
+ console.warn(
165
+ chalk.yellow(` ⚠ Tasks file is corrupt or unreadable (${path.basename(tasksFile)}).`) +
166
+ chalk.gray(` Re-run \`ai-spec tasks <spec>\` to regenerate.`)
167
+ );
168
+ return null;
160
169
  }
161
- return null;
162
170
  }
163
171
 
164
172
  /** Persist a single task's status to the tasks JSON file (checkpoint). */