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/.claude/settings.local.json +10 -0
- package/README.md +11 -6
- package/RELEASE_LOG.md +102 -0
- package/cli/index.ts +50 -2
- package/core/code-generator.ts +13 -1
- package/core/error-feedback.ts +18 -7
- package/core/frontend-context-loader.ts +72 -24
- package/core/provider-utils.ts +90 -0
- package/core/reviewer.ts +46 -9
- package/core/run-logger.ts +136 -0
- package/core/run-snapshot.ts +84 -0
- package/core/spec-generator.ts +83 -67
- package/core/task-generator.ts +11 -3
- package/dist/cli/index.js +1347 -977
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/index.mjs +1346 -976
- package/dist/cli/index.mjs.map +1 -1
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +432 -231
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +432 -231
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/prompts/codegen.prompt.ts +54 -0
- package/purpose.md +159 -32
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
|
-
*
|
|
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
|
|
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/
|
|
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/
|
|
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
|
|
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.
|
|
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.
|
|
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
|
+
}
|
package/core/spec-generator.ts
CHANGED
|
@@ -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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
package/core/task-generator.ts
CHANGED
|
@@ -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
|
-
|
|
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). */
|