ai-spec-dev 0.33.0 → 0.36.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.
Files changed (64) hide show
  1. package/.claude/commands/add-lesson.md +34 -0
  2. package/.claude/commands/check-layers.md +65 -0
  3. package/.claude/commands/installed-deps.md +35 -0
  4. package/.claude/commands/recall-lessons.md +40 -0
  5. package/.claude/commands/scan-singletons.md +45 -0
  6. package/.claude/commands/verify-imports.md +48 -0
  7. package/.claude/settings.local.json +11 -1
  8. package/README.md +531 -213
  9. package/RELEASE_LOG.md +424 -0
  10. package/cli/commands/config.ts +18 -0
  11. package/cli/commands/create.ts +1248 -0
  12. package/cli/commands/dashboard.ts +62 -0
  13. package/cli/commands/init.ts +45 -8
  14. package/cli/commands/mock.ts +175 -0
  15. package/cli/commands/scan.ts +99 -0
  16. package/cli/commands/types.ts +69 -0
  17. package/cli/commands/vcr.ts +70 -0
  18. package/cli/index.ts +34 -2517
  19. package/cli/utils.ts +4 -0
  20. package/core/code-generator.ts +6 -4
  21. package/core/combined-generator.ts +13 -3
  22. package/core/dashboard-generator.ts +340 -0
  23. package/core/design-dialogue.ts +124 -0
  24. package/core/dsl-extractor.ts +9 -1
  25. package/core/dsl-feedback.ts +41 -5
  26. package/core/dsl-validator.ts +32 -0
  27. package/core/error-feedback.ts +46 -2
  28. package/core/key-store.ts +5 -4
  29. package/core/project-index.ts +301 -0
  30. package/core/provider-utils.ts +39 -4
  31. package/core/reviewer.ts +84 -6
  32. package/core/run-logger.ts +109 -3
  33. package/core/run-trend.ts +24 -4
  34. package/core/self-evaluator.ts +39 -11
  35. package/core/spec-generator.ts +14 -8
  36. package/core/task-generator.ts +17 -0
  37. package/core/types-generator.ts +219 -0
  38. package/core/vcr.ts +210 -0
  39. package/dist/cli/index.js +7407 -5643
  40. package/dist/cli/index.js.map +1 -1
  41. package/dist/cli/index.mjs +7401 -5637
  42. package/dist/cli/index.mjs.map +1 -1
  43. package/dist/index.d.mts +34 -5
  44. package/dist/index.d.ts +34 -5
  45. package/dist/index.js +497 -232
  46. package/dist/index.js.map +1 -1
  47. package/dist/index.mjs +495 -233
  48. package/dist/index.mjs.map +1 -1
  49. package/docs-assets/purpose/architecture-overview.svg +64 -0
  50. package/docs-assets/purpose/create-pipeline.svg +113 -0
  51. package/docs-assets/purpose/task-layering.svg +74 -0
  52. package/package.json +1 -1
  53. package/prompts/codegen.prompt.ts +97 -9
  54. package/prompts/design.prompt.ts +59 -0
  55. package/prompts/spec.prompt.ts +8 -1
  56. package/prompts/tasks.prompt.ts +27 -2
  57. package/purpose.md +600 -174
  58. package/tests/code-generator.test.ts +253 -0
  59. package/tests/context-loader.test.ts +207 -0
  60. package/tests/dsl-validator.test.ts +105 -0
  61. package/tests/openapi-exporter.test.ts +310 -0
  62. package/tests/reviewer.test.ts +214 -0
  63. package/tests/spec-generator.test.ts +228 -0
  64. package/tests/spec-versioning.test.ts +205 -0
@@ -74,6 +74,22 @@ export function validateDsl(raw: unknown): DslValidationResult {
74
74
  for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
75
75
  validateEndpoint(eps[i], `endpoints[${i}]`, errors);
76
76
  }
77
+ // ── Endpoint ID uniqueness ──────────────────────────────────────────────
78
+ const seenEpIds = new Set<string>();
79
+ for (let i = 0; i < Math.min(eps.length, MAX_ENDPOINTS); i++) {
80
+ const ep = eps[i] as Record<string, unknown> | null;
81
+ if (ep && typeof ep === "object" && typeof ep["id"] === "string") {
82
+ const id = ep["id"] as string;
83
+ if (seenEpIds.has(id)) {
84
+ errors.push({
85
+ path: `endpoints[${i}].id`,
86
+ message: `Duplicate endpoint id "${id}" — each endpoint must have a unique id`,
87
+ });
88
+ } else {
89
+ seenEpIds.add(id);
90
+ }
91
+ }
92
+ }
77
93
  }
78
94
 
79
95
  // ── behaviors (optional, but must be array if present) ────────────────────
@@ -149,6 +165,22 @@ function validateModel(
149
165
  for (let j = 0; j < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j++) {
150
166
  validateModelField(fields[j], `${path}.fields[${j}]`, errors);
151
167
  }
168
+ // ── Field name uniqueness within model ──────────────────────────────────
169
+ const seenFieldNames = new Set<string>();
170
+ for (let j = 0; j < Math.min(fields.length, MAX_FIELDS_PER_MODEL); j++) {
171
+ const f = fields[j] as Record<string, unknown> | null;
172
+ if (f && typeof f === "object" && typeof f["name"] === "string") {
173
+ const name = f["name"] as string;
174
+ if (seenFieldNames.has(name)) {
175
+ errors.push({
176
+ path: `${path}.fields[${j}].name`,
177
+ message: `Duplicate field name "${name}" — each field within a model must have a unique name`,
178
+ });
179
+ } else {
180
+ seenFieldNames.add(name);
181
+ }
182
+ }
183
+ }
152
184
  }
153
185
 
154
186
  // relations: optional array of strings
@@ -22,6 +22,22 @@ interface FixResult {
22
22
  explanation: string;
23
23
  }
24
24
 
25
+ // ─── Budgets ────────────────────────────────────────────────────────────────────
26
+
27
+ /**
28
+ * Maximum characters captured from a single command's output before parsing.
29
+ * ~10K tokens — enough for any realistic error listing; prevents a pathological
30
+ * build output (e.g. 10MB of warnings) from ballooning the AI context.
31
+ */
32
+ const MAX_COMMAND_OUTPUT_CHARS = 50_000;
33
+
34
+ /**
35
+ * Maximum characters of an existing file sent to the AI for auto-fix.
36
+ * ~12K tokens — covers large files; content beyond this is truncated with a
37
+ * notice so the AI knows it may be seeing an incomplete file.
38
+ */
39
+ const MAX_FIX_FILE_CHARS = 60_000;
40
+
25
41
  // ─── Error Detection ────────────────────────────────────────────────────────────
26
42
 
27
43
  function runCommand(cmd: string, cwd: string): { success: boolean; output: string } {
@@ -30,7 +46,13 @@ function runCommand(cmd: string, cwd: string): { success: boolean; output: strin
30
46
  return { success: true, output };
31
47
  } catch (err) {
32
48
  const e = err as { stdout?: string; stderr?: string; message?: string };
33
- return { success: false, output: e.stdout || e.stderr || e.message || "" };
49
+ const raw = e.stdout || e.stderr || e.message || "";
50
+ // Apply output budget: cap before parsing to prevent huge outputs from
51
+ // filling up the AI context on subsequent fix cycles.
52
+ const output = raw.length > MAX_COMMAND_OUTPUT_CHARS
53
+ ? raw.slice(0, MAX_COMMAND_OUTPUT_CHARS) + `\n... [output truncated at ${MAX_COMMAND_OUTPUT_CHARS} chars]`
54
+ : raw;
55
+ return { success: false, output };
34
56
  }
35
57
  }
36
58
 
@@ -328,6 +350,13 @@ async function attemptFix(
328
350
  const dslSection = dsl ? `\n${buildDslContextSection(dsl)}\n` : "";
329
351
  const errorSummary = fileErrors.map((e) => `[${e.source}] ${e.message}`).join("\n");
330
352
 
353
+ // Apply file content budget — very large files are truncated with a notice.
354
+ // The AI still has enough context to fix the errors (which reference specific lines).
355
+ const fileContent = existingContent.length > MAX_FIX_FILE_CHARS
356
+ ? existingContent.slice(0, MAX_FIX_FILE_CHARS) +
357
+ `\n\n// ... [file truncated at ${MAX_FIX_FILE_CHARS} chars — fix only the error lines above]`
358
+ : existingContent;
359
+
331
360
  const prompt = `Fix the following errors in the file.
332
361
 
333
362
  File: ${file}
@@ -336,7 +365,7 @@ ${dslSection}
336
365
  ${errorSummary}
337
366
 
338
367
  === Current File Content ===
339
- ${existingContent}
368
+ ${fileContent}
340
369
 
341
370
  Output ONLY the complete fixed file content. No markdown fences, no explanations.`;
342
371
 
@@ -394,6 +423,8 @@ export async function runErrorFeedback(
394
423
 
395
424
  if (buildCmd) console.log(chalk.gray(` Type-check: ${buildCmd}`));
396
425
 
426
+ let prevErrorCount = Infinity; // circuit-breaker: tracks error count from previous cycle
427
+
397
428
  for (let cycle = 1; cycle <= maxCycles; cycle++) {
398
429
  const allErrors: ErrorEntry[] = [];
399
430
 
@@ -462,6 +493,19 @@ export async function runErrorFeedback(
462
493
  return true;
463
494
  }
464
495
 
496
+ // Circuit breaker: if the fix cycle made no progress (error count did not
497
+ // decrease), stop immediately rather than spending another AI cycle.
498
+ if (allErrors.length >= prevErrorCount) {
499
+ console.log(
500
+ chalk.yellow(
501
+ `\n ⚠ Auto-fix made no progress (${allErrors.length} error(s) before and after). Stopping early.`
502
+ )
503
+ );
504
+ console.log(chalk.gray(" Manual intervention needed."));
505
+ return false;
506
+ }
507
+ prevErrorCount = allErrors.length;
508
+
465
509
  if (cycle < maxCycles) {
466
510
  console.log(chalk.cyan(`\n Attempting auto-fix (${allErrors.length} error(s))...`));
467
511
  await attemptFix(provider, allErrors, workingDir, dsl);
package/core/key-store.ts CHANGED
@@ -11,16 +11,17 @@ async function readStore(): Promise<KeyStore> {
11
11
  if (await fs.pathExists(KEY_STORE_FILE)) {
12
12
  return await fs.readJson(KEY_STORE_FILE);
13
13
  }
14
- } catch {
15
- // ignore corrupt file
14
+ } catch (err) {
15
+ console.warn(`Warning: Could not read key store at ${KEY_STORE_FILE}: ${(err as Error).message}. Using empty store.`);
16
16
  }
17
17
  return {};
18
18
  }
19
19
 
20
20
  async function writeStore(store: KeyStore): Promise<void> {
21
- await fs.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
22
- // Restrict permissions to owner only (600)
21
+ // Ensure file exists with restricted permissions BEFORE writing sensitive data
22
+ await fs.ensureFile(KEY_STORE_FILE);
23
23
  await fs.chmod(KEY_STORE_FILE, 0o600);
24
+ await fs.writeJson(KEY_STORE_FILE, store, { spaces: 2 });
24
25
  }
25
26
 
26
27
  export async function getSavedKey(provider: string): Promise<string | undefined> {
@@ -0,0 +1,301 @@
1
+ /**
2
+ * project-index.ts — Persistent project discovery & index.
3
+ *
4
+ * Scans a root directory for sub-projects (any dir with a recognisable
5
+ * project manifest), and maintains an incremental JSON index file at
6
+ * .ai-spec-index.json in the scan root.
7
+ *
8
+ * Incremental rules:
9
+ * - New project found → added with firstSeen = now
10
+ * - Existing project → techStack / type / role / hasConstitution refreshed, lastSeen = now
11
+ * - Previously indexed but directory gone → marked missing:true, NOT deleted
12
+ *
13
+ * The index is intentionally lightweight — no AI calls, pure filesystem scan.
14
+ */
15
+
16
+ import * as fs from "fs-extra";
17
+ import * as path from "path";
18
+ import { detectRepoType, RepoType, RepoRole, WORKSPACE_CONFIG_FILE } from "./workspace-loader";
19
+ import { CONSTITUTION_FILE } from "./constitution-generator";
20
+
21
+ export const INDEX_FILE = ".ai-spec-index.json";
22
+
23
+ // ─── Key dependency lists for tech-stack extraction ──────────────────────────
24
+
25
+ const KEY_DEPS: string[] = [
26
+ // Frameworks
27
+ "express", "fastify", "koa", "@nestjs/core", "hapi",
28
+ "next", "react", "vue", "nuxt", "svelte",
29
+ "react-native", "expo",
30
+ // DB / ORM
31
+ "prisma", "@prisma/client", "mongoose", "typeorm", "sequelize", "drizzle-orm",
32
+ // Auth
33
+ "jsonwebtoken", "passport", "next-auth", "@clerk/nextjs",
34
+ // Build / Lang
35
+ "typescript", "vite", "webpack", "esbuild", "turbo",
36
+ // Testing
37
+ "jest", "vitest", "mocha", "cypress", "playwright",
38
+ // Infra
39
+ "redis", "bull", "socket.io", "graphql", "@trpc/server",
40
+ ];
41
+
42
+ // ─── Types ────────────────────────────────────────────────────────────────────
43
+
44
+ export interface ProjectEntry {
45
+ /** Directory name */
46
+ name: string;
47
+ /** Path relative to scanRoot */
48
+ path: string;
49
+ type: RepoType;
50
+ role: RepoRole;
51
+ /** Key dependencies detected (subset of package.json deps or language markers) */
52
+ techStack: string[];
53
+ /** Whether .ai-spec-constitution.md exists */
54
+ hasConstitution: boolean;
55
+ /** Whether .ai-spec-workspace.json exists (this repo is a workspace root) */
56
+ hasWorkspace: boolean;
57
+ /** ISO timestamp of first discovery */
58
+ firstSeen: string;
59
+ /** ISO timestamp of last successful scan */
60
+ lastSeen: string;
61
+ /** true when the directory no longer exists on disk */
62
+ missing?: boolean;
63
+ }
64
+
65
+ export interface ProjectIndex {
66
+ /** Absolute path of the directory that was scanned */
67
+ scanRoot: string;
68
+ /** ISO timestamp of last scan */
69
+ lastScanned: string;
70
+ projects: ProjectEntry[];
71
+ }
72
+
73
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
74
+
75
+ /** Directories to always skip during scan */
76
+ const SKIP_DIRS = new Set([
77
+ "node_modules", ".git", ".svn", "dist", "build", "out", ".next",
78
+ ".nuxt", "coverage", ".turbo", ".cache", "__pycache__", "vendor",
79
+ ".ai-spec-vcr", ".ai-spec-logs", "specs",
80
+ ]);
81
+
82
+ /** Manifest files that identify a directory as a project root */
83
+ const MANIFEST_FILES = [
84
+ "package.json",
85
+ "go.mod",
86
+ "Cargo.toml",
87
+ "pom.xml",
88
+ "build.gradle",
89
+ "build.gradle.kts",
90
+ "requirements.txt",
91
+ "pyproject.toml",
92
+ "setup.py",
93
+ "composer.json",
94
+ ];
95
+
96
+ async function isProjectRoot(absPath: string): Promise<boolean> {
97
+ for (const manifest of MANIFEST_FILES) {
98
+ if (await fs.pathExists(path.join(absPath, manifest))) return true;
99
+ }
100
+ return false;
101
+ }
102
+
103
+ async function extractTechStack(absPath: string, type: RepoType): Promise<string[]> {
104
+ const stack: string[] = [];
105
+
106
+ // Language marker for non-Node projects
107
+ if (type === "go") stack.push("go");
108
+ if (type === "rust") stack.push("rust");
109
+ if (type === "java") stack.push("java");
110
+ if (type === "python") stack.push("python");
111
+ if (type === "php") stack.push("php");
112
+
113
+ const pkgPath = path.join(absPath, "package.json");
114
+ if (!(await fs.pathExists(pkgPath))) return stack;
115
+
116
+ let pkg: Record<string, unknown> = {};
117
+ try { pkg = await fs.readJson(pkgPath); } catch { return stack; }
118
+
119
+ const allDeps = {
120
+ ...((pkg.dependencies as Record<string, string>) ?? {}),
121
+ ...((pkg.devDependencies as Record<string, string>) ?? {}),
122
+ };
123
+ const depKeys = new Set(Object.keys(allDeps));
124
+
125
+ for (const dep of KEY_DEPS) {
126
+ if (depKeys.has(dep)) stack.push(dep);
127
+ }
128
+
129
+ return stack;
130
+ }
131
+
132
+ // ─── Scan ─────────────────────────────────────────────────────────────────────
133
+
134
+ /**
135
+ * Discover all project roots under `rootDir` up to `maxDepth` levels deep.
136
+ * Returns paths relative to rootDir.
137
+ */
138
+ async function discoverProjects(
139
+ rootDir: string,
140
+ maxDepth: number
141
+ ): Promise<string[]> {
142
+ const found: string[] = [];
143
+
144
+ async function walk(absDir: string, depth: number): Promise<void> {
145
+ if (depth > maxDepth) return;
146
+
147
+ let entries: fs.Dirent[];
148
+ try {
149
+ entries = await fs.readdir(absDir, { withFileTypes: true });
150
+ } catch {
151
+ return;
152
+ }
153
+
154
+ for (const entry of entries) {
155
+ if (!entry.isDirectory()) continue;
156
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith(".")) continue;
157
+
158
+ const childAbs = path.join(absDir, entry.name);
159
+
160
+ // Skip git worktrees — they have a .git *file* (not directory)
161
+ const gitPath = path.join(childAbs, ".git");
162
+ if (await fs.pathExists(gitPath)) {
163
+ const gitStat = await fs.stat(gitPath);
164
+ if (gitStat.isFile()) continue; // git worktree — skip
165
+ }
166
+
167
+ if (await isProjectRoot(childAbs)) {
168
+ found.push(path.relative(rootDir, childAbs));
169
+ // Don't recurse into a project root — avoids picking up nested node_modules etc.
170
+ } else {
171
+ await walk(childAbs, depth + 1);
172
+ }
173
+ }
174
+ }
175
+
176
+ await walk(rootDir, 0);
177
+ return found;
178
+ }
179
+
180
+ // ─── Index load / save ────────────────────────────────────────────────────────
181
+
182
+ export async function loadIndex(scanRoot: string): Promise<ProjectIndex | null> {
183
+ const filePath = path.join(scanRoot, INDEX_FILE);
184
+ try {
185
+ return await fs.readJson(filePath);
186
+ } catch {
187
+ return null;
188
+ }
189
+ }
190
+
191
+ export async function saveIndex(scanRoot: string, index: ProjectIndex): Promise<string> {
192
+ const filePath = path.join(scanRoot, INDEX_FILE);
193
+ await fs.writeJson(filePath, index, { spaces: 2 });
194
+ return filePath;
195
+ }
196
+
197
+ // ─── Incremental merge ────────────────────────────────────────────────────────
198
+
199
+ export interface ScanResult {
200
+ index: ProjectIndex;
201
+ added: ProjectEntry[];
202
+ updated: ProjectEntry[];
203
+ unchanged: ProjectEntry[];
204
+ nowMissing: ProjectEntry[];
205
+ }
206
+
207
+ /**
208
+ * Run an incremental scan of `scanRoot`, merge with the existing index, and
209
+ * return the updated index along with a change summary.
210
+ */
211
+ export async function runScan(
212
+ scanRoot: string,
213
+ maxDepth = 2
214
+ ): Promise<ScanResult> {
215
+ const now = new Date().toISOString();
216
+ const existing = await loadIndex(scanRoot);
217
+ const existingMap = new Map<string, ProjectEntry>(
218
+ (existing?.projects ?? []).map((p) => [p.path, p])
219
+ );
220
+
221
+ const discoveredPaths = await discoverProjects(scanRoot, maxDepth);
222
+
223
+ const added: ProjectEntry[] = [];
224
+ const updated: ProjectEntry[] = [];
225
+ const unchanged: ProjectEntry[] = [];
226
+ const seenPaths = new Set<string>();
227
+
228
+ for (const relPath of discoveredPaths) {
229
+ const absPath = path.join(scanRoot, relPath);
230
+ seenPaths.add(relPath);
231
+
232
+ const { type, role } = await detectRepoType(absPath);
233
+ const techStack = await extractTechStack(absPath, type);
234
+ const hasConstitution = await fs.pathExists(path.join(absPath, CONSTITUTION_FILE));
235
+ const hasWorkspace = await fs.pathExists(path.join(absPath, WORKSPACE_CONFIG_FILE));
236
+ const name = path.basename(relPath);
237
+
238
+ const prev = existingMap.get(relPath);
239
+ if (!prev) {
240
+ const entry: ProjectEntry = {
241
+ name,
242
+ path: relPath,
243
+ type,
244
+ role,
245
+ techStack,
246
+ hasConstitution,
247
+ hasWorkspace,
248
+ firstSeen: now,
249
+ lastSeen: now,
250
+ };
251
+ added.push(entry);
252
+ existingMap.set(relPath, entry);
253
+ } else {
254
+ // Check if anything changed
255
+ const changed =
256
+ prev.type !== type ||
257
+ prev.role !== role ||
258
+ prev.hasConstitution !== hasConstitution ||
259
+ prev.hasWorkspace !== hasWorkspace ||
260
+ JSON.stringify(prev.techStack.sort()) !== JSON.stringify(techStack.sort());
261
+
262
+ const entry: ProjectEntry = {
263
+ ...prev,
264
+ type,
265
+ role,
266
+ techStack,
267
+ hasConstitution,
268
+ hasWorkspace,
269
+ lastSeen: now,
270
+ missing: undefined, // clear missing flag if it came back
271
+ };
272
+ existingMap.set(relPath, entry);
273
+
274
+ if (changed) {
275
+ updated.push(entry);
276
+ } else {
277
+ unchanged.push(entry);
278
+ }
279
+ }
280
+ }
281
+
282
+ // Mark previously known projects as missing if their directory is gone
283
+ const nowMissing: ProjectEntry[] = [];
284
+ for (const [relPath, entry] of existingMap) {
285
+ if (!seenPaths.has(relPath) && !entry.missing) {
286
+ const gone: ProjectEntry = { ...entry, missing: true };
287
+ existingMap.set(relPath, gone);
288
+ nowMissing.push(gone);
289
+ }
290
+ }
291
+
292
+ const projects = [...existingMap.values()].sort((a, b) => a.path.localeCompare(b.path));
293
+
294
+ const index: ProjectIndex = {
295
+ scanRoot,
296
+ lastScanned: now,
297
+ projects,
298
+ };
299
+
300
+ return { index, added, updated, unchanged, nowMissing };
301
+ }
@@ -22,14 +22,49 @@ function classifyError(err: unknown, label: string): ProviderError {
22
22
  const status = e.status ?? e.response?.status;
23
23
 
24
24
  if (status === 401 || status === 403)
25
- return new ProviderError(`Auth error — check your API key (${label})`, "auth", err);
25
+ return new ProviderError(
26
+ `Auth error (${label}): API key is invalid or expired.\n` +
27
+ ` → Check that the correct API key is set in your environment or ~/.ai-spec-keys.json\n` +
28
+ ` → Run "ai-spec model" to reconfigure your provider and key`,
29
+ "auth", err
30
+ );
26
31
  if (status === 429)
27
- return new ProviderError(`Rate limit hit (${label}) — try again later or switch provider`, "rate_limit", err);
32
+ return new ProviderError(
33
+ `Rate limit hit (${label}): too many requests.\n` +
34
+ ` → Wait a few minutes and retry, or switch to a different provider/model\n` +
35
+ ` → Check your provider's billing dashboard for quota status`,
36
+ "rate_limit", err
37
+ );
28
38
  if ((e as Error & { _timeout?: boolean })._timeout || e.message?.toLowerCase().includes("timed out"))
29
39
  return new ProviderError(`Request timed out (${label})`, "timeout", err);
30
40
  if (e.code === "ECONNRESET" || e.code === "ENOTFOUND" || e.code === "ECONNREFUSED")
31
- return new ProviderError(`Network error — check connection/proxy (${label}): ${e.message}`, "network", err);
32
- return new ProviderError(`Provider error (${label}): ${e.message}`, "provider", err);
41
+ return new ProviderError(
42
+ `Network error (${label}): ${e.message}\n` +
43
+ ` → Check your internet connection and proxy settings (HTTPS_PROXY)\n` +
44
+ ` → If behind a firewall, ensure the provider's API endpoint is reachable`,
45
+ "network", err
46
+ );
47
+
48
+ // Check for common model-not-found errors
49
+ const msg = e.message ?? "";
50
+ if (status === 404 || msg.includes("model") && (msg.includes("not found") || msg.includes("does not exist")))
51
+ return new ProviderError(
52
+ `Model not found (${label}): ${msg}\n` +
53
+ ` → Run "ai-spec model" to see available models for your provider\n` +
54
+ ` → The model name may have changed — check your provider's documentation`,
55
+ "provider", err
56
+ );
57
+
58
+ // Check for insufficient balance / quota exhaustion
59
+ if (msg.includes("insufficient") || msg.includes("quota") || msg.includes("balance"))
60
+ return new ProviderError(
61
+ `Quota/balance error (${label}): ${msg}\n` +
62
+ ` → Check your provider's billing dashboard\n` +
63
+ ` → Consider switching to a different provider with "ai-spec model"`,
64
+ "provider", err
65
+ );
66
+
67
+ return new ProviderError(`Provider error (${label}): ${msg}`, "provider", err);
33
68
  }
34
69
 
35
70
  function isRetryable(err: unknown): boolean {
package/core/reviewer.ts CHANGED
@@ -4,10 +4,37 @@ import * as path from "path";
4
4
  import * as fs from "fs-extra";
5
5
  import { AIProvider } from "./spec-generator";
6
6
  import {
7
+ specComplianceSystemPrompt,
7
8
  reviewArchitectureSystemPrompt,
8
9
  reviewImplementationSystemPrompt,
9
10
  reviewImpactComplexitySystemPrompt,
10
11
  } from "../prompts/codegen.prompt";
12
+ import { CONSTITUTION_FILE } from "./constitution-generator";
13
+
14
+ // ─── Constitution Lessons Helper ──────────────────────────────────────────────
15
+
16
+ /**
17
+ * Extract the §9 accumulated lessons section from a constitution file.
18
+ * Returns null if the section is absent or the file cannot be read.
19
+ */
20
+ async function loadAccumulatedLessons(projectRoot: string): Promise<string | null> {
21
+ const constitutionPath = path.join(projectRoot, CONSTITUTION_FILE);
22
+ let content: string;
23
+ try {
24
+ content = await fs.readFile(constitutionPath, "utf-8");
25
+ } catch {
26
+ return null;
27
+ }
28
+ const marker = "## 9. 积累教训";
29
+ const idx = content.indexOf(marker);
30
+ if (idx === -1) return null;
31
+ // Extract from §9 header to end of file (or next top-level section)
32
+ const section = content.slice(idx);
33
+ const nextSection = section.slice(marker.length).match(/\n## \d/);
34
+ return nextSection
35
+ ? section.slice(0, marker.length + nextSection.index!)
36
+ : section;
37
+ }
11
38
 
12
39
  // ─── Review History ────────────────────────────────────────────────────────────
13
40
 
@@ -15,6 +42,7 @@ interface ReviewHistoryEntry {
15
42
  date: string;
16
43
  specFile: string;
17
44
  score: number;
45
+ complianceScore?: number;
18
46
  topIssues: string[];
19
47
  impactLevel?: "低" | "中" | "高";
20
48
  complexityLevel?: "低" | "中" | "高";
@@ -55,6 +83,18 @@ function extractScore(reviewText: string): number {
55
83
  return match ? parseFloat(match[1]) : 0;
56
84
  }
57
85
 
86
+ /** Extract compliance score from Pass 0 output (looks for "ComplianceScore: X/10") */
87
+ export function extractComplianceScore(complianceText: string): number {
88
+ const match = complianceText.match(/ComplianceScore:\s*(\d+(?:\.\d+)?)\s*\/\s*10/i);
89
+ return match ? parseFloat(match[1]) : 0;
90
+ }
91
+
92
+ /** Count missing requirements from Pass 0 output */
93
+ export function extractMissingCount(complianceText: string): number {
94
+ const summaryMatch = complianceText.match(/Missing:\s*(\d+)/i);
95
+ return summaryMatch ? parseInt(summaryMatch[1], 10) : 0;
96
+ }
97
+
58
98
  /** Extract impact level from Pass 3 review ("影响等级:低/中/高") */
59
99
  function extractImpactLevel(reviewText: string): "低" | "中" | "高" | undefined {
60
100
  const match = reviewText.match(/影响等级[::]\s*(低|中|高)/);
@@ -126,8 +166,9 @@ export class CodeReviewer {
126
166
  }
127
167
 
128
168
  /**
129
- * Three-pass review:
130
- * Pass 1architecture (spec compliance, layer separation, auth)
169
+ * Four-pass review:
170
+ * Pass 0 — spec compliance (exhaustive requirement coverage audit)
171
+ * Pass 1 — architecture (layer separation, contract design, auth posture)
131
172
  * Pass 2 — implementation details (validation, error handling, edge cases)
132
173
  * + historical issue recurrence check
133
174
  * Pass 3 — impact assessment + code complexity
@@ -137,11 +178,43 @@ export class CodeReviewer {
137
178
  codeContext: string,
138
179
  specFile?: string
139
180
  ): Promise<string> {
140
- console.log(chalk.gray(" Pass 1/3: Architecture review..."));
181
+ // ── Pass 0: Spec Compliance (skip if no spec provided) ───────────────────
182
+ let complianceReview = "";
183
+ if (specContent && specContent.trim() && specContent !== "(No spec — review for general code quality)") {
184
+ console.log(chalk.gray(" Pass 0/3: Spec compliance check..."));
185
+ const compliancePrompt = `Check whether the implementation covers every requirement in the spec.
141
186
 
142
- // ── Pass 1: Architecture ──────────────────────────────────────────────────
143
- const archPrompt = `Review the architecture of this change.
187
+ === Feature Spec ===
188
+ ${specContent}
189
+
190
+ === Code ===
191
+ ${codeContext}`;
192
+ complianceReview = await this.provider.generate(compliancePrompt, specComplianceSystemPrompt);
193
+
194
+ // Surface compliance score immediately
195
+ const complianceScore = extractComplianceScore(complianceReview);
196
+ const missingCount = extractMissingCount(complianceReview);
197
+ if (complianceScore > 0) {
198
+ const scoreColor = complianceScore >= 8 ? chalk.green : complianceScore >= 6 ? chalk.yellow : chalk.red;
199
+ console.log(
200
+ chalk.gray(" Pass 0 result: ") +
201
+ scoreColor(`ComplianceScore ${complianceScore}/10`) +
202
+ (missingCount > 0 ? chalk.red(` · ${missingCount} missing requirement(s)`) : chalk.green(" · all requirements covered"))
203
+ );
204
+ }
205
+ }
144
206
 
207
+ console.log(chalk.gray(` Pass 1/3: Architecture review...`));
208
+
209
+ // ── Pass 1: Architecture (+ §9 lessons cross-check) ──────────────────────
210
+ const accumulatedLessons = await loadAccumulatedLessons(this.projectRoot);
211
+ const archPrompt = `Review the architecture of this change.
212
+ ${complianceReview
213
+ ? `\n=== Spec Compliance Report (Pass 0 — already audited, do NOT re-audit missing requirements) ===\n${complianceReview}\n`
214
+ : ""}
215
+ ${accumulatedLessons
216
+ ? `\n=== §9 历史积累教训 (Accumulated Lessons — check if any are repeated in this code) ===\n${accumulatedLessons}\n`
217
+ : ""}
145
218
  === Feature Spec ===
146
219
  ${specContent || "(No spec — review for general code quality)"}
147
220
 
@@ -189,10 +262,14 @@ ${implReview}`;
189
262
 
190
263
  // ── Combine ───────────────────────────────────────────────────────────────
191
264
  const sep = "─".repeat(52);
192
- const combined = `${archReview}\n\n${sep}\n\n${implReview}\n\n${sep}\n\n${impactReview}`;
265
+ const parts = complianceReview
266
+ ? [complianceReview, archReview, implReview, impactReview]
267
+ : [archReview, implReview, impactReview];
268
+ const combined = parts.join(`\n\n${sep}\n\n`);
193
269
 
194
270
  // ── Persist history ───────────────────────────────────────────────────────
195
271
  const score = extractScore(implReview) || extractScore(archReview);
272
+ const complianceScore = extractComplianceScore(complianceReview);
196
273
  const topIssues = extractTopIssues(implReview);
197
274
  const impactLevel = extractImpactLevel(impactReview);
198
275
  const complexityLevel = extractComplexityLevel(impactReview);
@@ -201,6 +278,7 @@ ${implReview}`;
201
278
  date: new Date().toISOString().slice(0, 10),
202
279
  specFile: path.relative(this.projectRoot, specFile),
203
280
  score,
281
+ ...(complianceScore > 0 ? { complianceScore } : {}),
204
282
  topIssues,
205
283
  ...(impactLevel ? { impactLevel } : {}),
206
284
  ...(complexityLevel ? { complexityLevel } : {}),